From 520ee741c444809de473db9170928f998a28eafa Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 5 Oct 2026 21:35:10 +0000 Subject: [PATCH 1/7] Install the 8 + 3 tables with setup and fix the quality slider and cache preflight Setup always installs the four three-step refinement tables (332 MiB), so the default 8 + 3 works offline, and new installations prepare every quality level by default. Existing installations keep their choice. Cache preflight: fetch the tables the encoder will select for reference audio (not image-to-video tables), download files in parallel with the sources ranked at setup instead of a new speed test per request, set aside a damaged shared copy instead of blocking every request, check free space, and keep the ComfyUI console quiet. The engine reads each table file from whichever folder holds it, so a table split between folders is never downloaded again. Quality slider: each level is a complete plan. Editing first-pass steps turns two-pass off only for committed values and restores it at 8; opening the studio no longer rewrites a workflow; saved 8 + 2 workflows move to 8 + 3 once; Chinese level names; estimates also work on Macs. Diagnostics keep the optional table counters. Release 0.2.0. --- freevideo_engine/adaln.py | 12 +- freevideo_engine/adaln_assets.py | 7 +- freevideo_engine/bootstrap.py | 12 +- freevideo_engine/comfy_bridge.py | 5 +- freevideo_engine/comfy_library.py | 3 +- freevideo_engine/comfy_setup.py | 3 +- freevideo_engine/diagnostic_summary.py | 5 +- freevideo_engine/effort_forecast.py | 13 ++- freevideo_engine/generate.py | 2 +- freevideo_engine/launcher_session.py | 3 +- freevideo_engine/provision.py | 6 +- freevideo_engine/release_notes.json | 24 ++-- freevideo_engine/sampling_assets.py | 156 ++++++++++++++++++------- web/freevideo.js | 9 ++ web/sampling_effort.js | 30 +++-- web/setup.js | 2 +- web/share.js | 7 +- web/studio.js | 48 +++++--- 18 files changed, 232 insertions(+), 115 deletions(-) diff --git a/freevideo_engine/adaln.py b/freevideo_engine/adaln.py index c0381fe..85576ca 100644 --- a/freevideo_engine/adaln.py +++ b/freevideo_engine/adaln.py @@ -64,12 +64,12 @@ def __init__(self, root, source_id, steps, task='t2va', *, manifest=None, self.optional_download_bytes = 0 self.root = Path(root) / assets.directory(self.identity) self.producer = None + self.shared = None if self.asset is None: + # Published tables installed by setup or the request preflight. from .paths import model_root from .sampling_assets import cache_root - shared = cache_root(model_root()) / assets.directory(self.identity) - if shared.is_dir(): - self.root = shared + self.shared = cache_root(model_root()) / assets.directory(self.identity) if self.asset is not None: if self.asset['directory'] != self.root.name: raise ValueError('AdaLN asset identity/path mismatch') @@ -126,6 +126,9 @@ def load(self, index, steps): raise ValueError('AdaLN cache schedule length mismatch') path = self.root / f'{index:02d}.safetensors' marker = path.with_suffix('.json') + if self.asset is None and not (path.is_file() and marker.is_file()) and self.shared is not None: + path = self.shared / path.name + marker = path.with_suffix('.json') if self.asset is not None: row = next((r for r in self.asset['files'] if r['index'] == index), None) if row is None: @@ -133,7 +136,8 @@ def load(self, index, steps): elif not path.is_file() or not marker.is_file(): if self.optional_asset is None: return None - row = assets.download_table(self.root, self.optional_asset, index) + path.parent.mkdir(parents=True, exist_ok=True) + row = assets.download_table(path.parent, self.optional_asset, index) self.optional_downloaded.add(index) self.optional_download_bytes += row['bytes'] else: diff --git a/freevideo_engine/adaln_assets.py b/freevideo_engine/adaln_assets.py index 1c65448..ddd5a37 100644 --- a/freevideo_engine/adaln_assets.py +++ b/freevideo_engine/adaln_assets.py @@ -171,11 +171,12 @@ def optional_table(expected, manifest): return dict(table, download=dict(repo=optional['repo'], revision=optional['revision'], prefix=prefix)) -def _download_plan(): +def _download_plan(root=None): from . import network from .paths import data_root + root = Path(root) if root is not None else data_root() plan = network.installed_plan() - machine = data_root() / 'machine.json' + machine = root / 'machine.json' if not plan and machine.is_file(): installed = json.loads(machine.read_text(encoding='utf-8')) plan = installed.get('network', {}) or {} @@ -183,7 +184,7 @@ def _download_plan(): setup = Path(installed['setup_run']) / 'plan.json' if setup.is_file(): plan = json.loads(setup.read_text(encoding='utf-8')).get('network', {}) or {} - plan.setdefault('download_settings_path', str(data_root() / 'download-settings.json')) + plan.setdefault('download_settings_path', str(root / 'download-settings.json')) plan.setdefault('sources', {}).setdefault('models', [{'id': 'official'}, {'id': 'hf-mirror'}]) return plan diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 144fcae..67aca45 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -313,10 +313,11 @@ def plan(args, *, local_progress=None): errors.append('No verified native-compatible prepared model is available. Source conversion is not supported on Mac.') files = required_models(json.loads((PACKAGE / 'model_files.json').read_text(encoding='utf-8')), reuse_cache or prepared) files += prepared_model.files(prepared) - sampling_caches = bool(getattr(args, 'sampling_caches', False)) - if sampling_caches: - from .sampling_assets import files as sampling_files - files += sampling_files() + sampling_caches = bool(getattr(args, 'sampling_caches', True)) + if prepared or reuse_cache: + # Published tables match these weights; source conversions compute their own. + from .sampling_assets import install_files + files += install_files(sampling_caches) local_reuse = None local_folder = getattr(args, 'reuse_models', None) local_manifest = getattr(args, 'reuse_models_manifest', None) @@ -1419,7 +1420,8 @@ def main(argv=None): parser.add_argument('--environment', choices=('unified', 'dual'), help='New installs default to unified; updates retain their saved layout. Existing environments are kept when switching.') parser.add_argument('--models', type=Path, help='Reuse/download official model files in this directory') - parser.add_argument('--sampling-caches', action='store_true', help='Install all four quality levels and reference-mode sampling caches in advance') + parser.add_argument('--sampling-caches', action=argparse.BooleanOptionalAction, default=True, + help='Install every quality level in advance (default). The default 8 + 3 refinement tables are always installed.') parser.add_argument('--model-source', choices=('prepared', 'source'), default='prepared', help='Default: download a pinned slim model matching the GPU format. source: explicitly download original weights and convert locally') parser.add_argument('--encoder-models', type=Path, help='Directory containing text_encoders/') diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index 8f56860..0571c23 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -563,13 +563,14 @@ def inspect_saved(check): send_progress({'label': 'Reused previous result', 'phase': 'complete', 'result_cache_hit': True}) return reused - from .sampling_assets import prepare as prepare_sampling_assets + from .sampling_assets import engine_task, prepare as prepare_sampling_assets def asset_progress(message): if progress: progress(dict(message, report_id=report_id)) preparation_started = time.monotonic() try: - preparation = prepare_sampling_assets(root, machine, planned, task_for(extra.get('media', {})), + # Reference audio selects its own tables; match the encoder's choice. + preparation = prepare_sampling_assets(root, machine, planned, engine_task(extra.get('media', {}), run), progress=asset_progress, interrupted=interrupted, environ=environment) except BaseException: elapsed = time.monotonic() - preparation_started diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py index 5e1fb2e..e073f78 100644 --- a/freevideo_engine/comfy_library.py +++ b/freevideo_engine/comfy_library.py @@ -142,7 +142,8 @@ async def sampling_estimate(request): canvas = geometry(int(request.query['width']), int(request.query['height']), seconds=float(request.query['seconds'])) _, machine = installation() - rows = await asyncio.to_thread(local_records, folder_paths.get_output_directory(), machine.get('gpu_uuid')) + device = machine.get('device_identity') if machine.get('device_backend') == 'mps' else machine.get('gpu_uuid') + rows = await asyncio.to_thread(local_records, folder_paths.get_output_directory(), device) task = request.query.get('task', 't2va') adapters = request.query.get('adapters') == '1' result = {name: {str(steps): estimate(rows, canvas, base_steps=steps, two_pass=enabled, diff --git a/freevideo_engine/comfy_setup.py b/freevideo_engine/comfy_setup.py index 543195e..639e9a3 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -248,8 +248,7 @@ def inspect(self, value): arguments.append('--frontend-separate') if frontend.get('download'): arguments.append('--frontend-download') - if value.get('sampling_caches') is True: - arguments.append('--sampling-caches') + arguments.append('--no-sampling-caches' if value.get('sampling_caches') is False else '--sampling-caches') if value.get('copy'): arguments.append('--copy-existing-models') self.selection = dict(root=str(root), arguments=arguments) diff --git a/freevideo_engine/diagnostic_summary.py b/freevideo_engine/diagnostic_summary.py index 64e9d85..a4330d2 100644 --- a/freevideo_engine/diagnostic_summary.py +++ b/freevideo_engine/diagnostic_summary.py @@ -65,7 +65,8 @@ def summary_report(report): if isinstance(shape, (list, tuple)) and len(shape) == 2 and all(type(x) is int and x > 0 for x in shape): result['geometry']['text_tokens'] = shape[0] for k in ('resident_blocks','pin_host_gb','head_chunk','window_batch','head_parallelism','ff_chunk','projection_chunk', - 'query_chunk','fp8_linears','resident_weight_bytes','pinned_model_bytes','pinned_host_allocated_bytes','steps'): + 'query_chunk','fp8_linears','resident_weight_bytes','pinned_model_bytes','pinned_host_allocated_bytes','steps', + 'adaln_optional_blocks','adaln_downloaded_files','adaln_downloaded_bytes'): if _number(config.get(k)) is not None: result['config'][k] = config[k] for k in ('prefetch','stream_weights','attention_cpu_outputs','grouped_attention_outputs','residual_offload','fp8_ff_recompute', @@ -73,7 +74,7 @@ def summary_report(report): if type(config.get(k)) is bool: result['config'][k] = config[k] for k, allowed in (('task', ('t2va','fl2va','ref2va')), ('linear_compute',('native-fp8','bf16-weight-only')), - ('adaln_mode', ('portable-model-asset','local-precompute','original-projections'))): + ('adaln_mode', ('portable-model-asset','optional-model-asset','local-precompute','original-projections'))): if config.get(k) in allowed: result['config'][k] = config[k] for k, allowed in (('fp8_gemm', ('torch','scaled-mm-epilogue','triton')), diff --git a/freevideo_engine/effort_forecast.py b/freevideo_engine/effort_forecast.py index bfbf1e8..0cdf6e2 100644 --- a/freevideo_engine/effort_forecast.py +++ b/freevideo_engine/effort_forecast.py @@ -17,6 +17,15 @@ def positive(value): return type(value) in (float, int) and math.isfinite(value) and value > 0 +def same_device(hardware, device): + """NVIDIA GPUs match by UUID; Macs by chip and unified memory (no UUID).""" + if isinstance(device, dict): + return (device.get('backend') == 'mps' and hardware.get('device_backend') == 'mps' + and bool(device.get('name')) and hardware.get('gpu_name') == device['name'] + and hardware.get('ram_total') == device.get('unified_ram_bytes')) + return bool(device) and hardware.get('gpu_uuid') == device + + def timing_record(report, gpu_uuid, system): if not isinstance(report, dict) or report.get('success') is not True: return None @@ -25,7 +34,7 @@ def timing_record(report, gpu_uuid, system): or video.get('first_pass_reused') or video.get('phase') != 'complete'): return None hardware = report.get('profile', {}).get('policy', {}).get('hardware', {}) - if not gpu_uuid or hardware.get('gpu_uuid') != gpu_uuid or hardware.get('system') != system: + if not same_device(hardware, gpu_uuid) or hardware.get('system') != system: return None shape = report.get('geometry', {}) plan = report.get('sampling_plan') or shape.get('sampling_plan') or {} @@ -119,7 +128,7 @@ def local_records(output_directory, gpu_uuid, *, system=None): """Bounded recent JSON reads, cached between slider changes; never open MP4s.""" system = system or platform.system() root = Path(output_directory).resolve() / 'FreeVideo' - key = (str(root), gpu_uuid, system) + key = (str(root), json.dumps(gpu_uuid, sort_keys=True), system) now = time.monotonic() cached = _CACHE.get(key) if cached and now - cached[0] < 15: diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index 098d04d..f373f2d 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -560,7 +560,7 @@ def interrupted(signum, frame): tokens = next((row['observation'].get('text_tokens', row.get('geometry', {}).get('text_tokens')) for row in reversed(observations) if row['observation'].get('conditioning_sha256') == condition_hash), None) - if state and args.attention == 'auto' and tokens is not None and sampling_plan['version'] == 1: + if state and args.attention == 'auto' and tokens is not None and sampling_plan['version'] in (1, 3): profile, applied = tuning.apply_profile(state, profile, canvas, tokens) report['profile'] = profile report['tuning']['profile_id'] = applied diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index f941d87..6ef8321 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -51,7 +51,8 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm home = Path(os.environ.get('USERPROFILE') or os.environ.get('HOME') or launcher_root().parent) self.form = dict(comfy='', destination=str(home / 'FreeVideo'), engine='', python='', url='http://127.0.0.1:8188', models='', model_dirs=[], model_method='auto', environment_method='auto', - separate=False, repair=False, new_comfy=True, offline_runtime='', offline_models=[], sampling_caches=False) + separate=False, repair=False, new_comfy=True, offline_runtime='', offline_models=[], + sampling_caches=not saved.get('installation')) self.form.update({k: v for k, v in saved.items() if k in self.form}) if 'environment_method' not in saved and self.form['offline_runtime']: self.form['environment_method'] = 'manual' diff --git a/freevideo_engine/provision.py b/freevideo_engine/provision.py index a165b69..23f4ec7 100644 --- a/freevideo_engine/provision.py +++ b/freevideo_engine/provision.py @@ -70,9 +70,9 @@ def models(plan): prepared = plan.get('prepared_model') files = list(required_models(json.loads((PACKAGE / 'model_files.json').read_text(encoding='utf-8')), plan.get('reuse_cache') or prepared)) files += prepared_model.files(prepared) - if plan.get('sampling_caches'): - from .sampling_assets import files as sampling_files - files += sampling_files() + if prepared or plan.get('reuse_cache'): + from .sampling_assets import install_files + files += install_files(bool(plan.get('sampling_caches'))) mode = plan.get('verification', 'auto') from .local_models import key as local_key, import_file local_files, missing = [], [] diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 3134b47..4b06b90 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,22 +1,24 @@ { "schema": 1, - "product_version": "0.1.3", + "product_version": "0.2.0", "en": { - "summary": "Four quality levels, improved two-pass sampling, and sharing exports.", + "summary": "Four quality levels, a new two-pass refinement and sharing exports.", "changes": [ - "Choose Light, Medium, High or Max with the quality slider. Light enables two-pass acceleration by default; higher levels use 12, 16 or 20 steps.", - "Two-pass sampling now uses 8 initial steps and an independent 3-step refinement schedule.", - "Optionally install all quality caches during setup, or download only missing caches before your first run. Download time is kept separate from generation time.", - "Export a sharing image or video with generation statistics." + "Choose Light, Medium, High or Max with the quality slider. Light uses two-pass acceleration; the higher levels sample 12, 16 or 20 steps.", + "Two-pass generation now refines with three independent steps after the first 8.", + "Setup installs every quality level by default. Existing installations download only what a generation needs, before it starts; download time is not counted as generation time.", + "Export a sharing image or video with generation statistics. The original video stays unchanged.", + "The launcher shows the FreeVideo wordmark." ] }, "zh": { - "summary": "四档质量滑块,更新二次采样与分享导出。", + "summary": "四档质量、全新二次采样与分享导出。", "changes": [ - "质量滑块提供四档。最低档默认开启二次采样加速,其余三档使用 12、16、20 步单采。", - "默认使用一采 8 步与独立时间表二采 3 步。", - "安装时可选下载全部档位缓存,也可首次运行前补装所需缓存;下载不计入生成耗时。", - "导出带生成统计的分享图片或视频。" + "质量滑块提供轻量、标准、精细、极致四档。轻量使用二次采样加速,其余三档采样 12、16、20 步。", + "二次采样改为一采 8 步后,独立二采 3 步。", + "安装时默认下载全部质量档位。已安装的版本只在生成前补装当次需要的文件,下载时间不计入生成耗时。", + "可导出带生成统计的分享图片或视频,原视频保持不变。", + "启动器左上角显示 FreeVideo 字标。" ] } } diff --git a/freevideo_engine/sampling_assets.py b/freevideo_engine/sampling_assets.py index 0781a67..d43b62e 100644 --- a/freevideo_engine/sampling_assets.py +++ b/freevideo_engine/sampling_assets.py @@ -1,4 +1,5 @@ -"""Optional sampling constants: install together or prepare before generation. +"""Sampling constants: the refinement tables every installation needs, plus the +optional quality levels, installed together or prepared before generation. Uses the installer's catalogs, measurements, connection preferences and verified resumable transfers. No model import, GPU work or prompt text is needed here. @@ -8,6 +9,9 @@ from pathlib import Path import time +COMMUNITY_PREFIX = 'community-sigma3-' +WORKERS = 6 + def tables(): from .prepared_model import CATALOG @@ -24,6 +28,11 @@ def tables(): return result +def required(table): + """The default 8 + 3 refinement must work offline after setup.""" + return table['download']['prefix'].startswith(COMMUNITY_PREFIX) + + def files(selected=None): result = [] for table in tables() if selected is None else selected: @@ -34,20 +43,56 @@ def files(selected=None): return result +def install_files(everything): + """Setup always installs the refinement tables; the option adds every level.""" + return files(None if everything else [t for t in tables() if required(t)]) + + @lru_cache(maxsize=1) def total_bytes(): - return sum(row['bytes'] for row in files()) + """Extra bytes the "prepare all quality levels" option adds to setup.""" + return sum(row['bytes'] for table in tables() if not required(table) for row in table['files']) def cache_root(model_root): return Path(model_root) / 'sampling-cache' +def engine_task(media, base=None): + """The task the engine selects after encoding these references. + + A reference video counts as audio when it has an audio stream, exactly as + the encoder decides; tables therefore match before the request starts. + """ + from .media_request import task_for + task = task_for(media) + if task != 'ref2va' or media.get('conditioning_info'): + return task + visual = audio = False + for ref in media.get('references', []): + visual |= ref['kind'] in ('image', 'video') + audio |= ref['kind'] == 'audio' + if ref['kind'] == 'video' and not audio: + path = Path(ref['path']) + if base is not None and not path.is_absolute(): + path = Path(base) / path + try: + import av + with av.open(str(path)) as container: + audio = bool(container.streams.audio) + except Exception: # Unreadable media fail in the encoder with details. + pass + return 'ref2va_av' if visual and audio else 'ref2va_audio' if audio else 'ref2va' + + def prepare(root, machine, sampling, task, *, progress, interrupted=None, environ=None): """Fetch only this request's missing tables before starting its timer.""" + from concurrent.futures import ThreadPoolExecutor + import shutil + import threading from . import adaln_assets as assets, network, provision - from .download_settings import read from .monitoring import save + from .refine_schedule import COMMUNITY cache = Path(machine['cache']) if not (cache / 'manifest.json').is_file(): return dict(seconds=0., downloaded_bytes=0) # Generation reports an invalid installation. @@ -55,9 +100,8 @@ def prepare(root, machine, sampling, task, *, progress, interrupted=None, enviro weights = assets.weight_identity(manifest) kind = 'i2va' if task in ('i2va', 'l2va', 'fl2va', 'ref2va') else task def needed(table): - independent = table['download']['prefix'].startswith('community-sigma3-') count = len(table['identity']['timesteps']) - return (count == 3 and sampling.get('refine_schedule') == 'community-sigma3-v1' if independent + return (count == 3 and sampling.get('refine_schedule') == COMMUNITY if required(table) else count == sampling['base_steps']) selected = [t for t in tables() if t.get('task') == kind and needed(t) and t['identity']['weights'] == weights] @@ -65,68 +109,90 @@ def needed(table): shared = cache_root(machine['model_root']) ledger = root / 'verified-models.json' stamps = json.loads(ledger.read_text(encoding='utf-8')) if ledger.is_file() else {} - missing = [] + stop = threading.Event() def check(): + if stop.is_set(): + raise RuntimeError('Sampling cache download stopped') if interrupted: interrupted() + missing, recorded = [], len(stamps) for table in selected: for row in table['files']: check() - # Keep valid locally computed constants and prior on-demand files. + # The engine verifies a cache's own receipt and content before use. local = assets.asset_path(cache, row['file']) - marker = local.with_suffix('.json') - if local.is_file() and marker.is_file(): - prior = json.loads(marker.read_text(encoding='utf-8')) - assets.check_table(local, prior, table['identity']) + if local.is_file() and local.with_suffix('.json').is_file(): continue path = assets.asset_path(shared, row['file']) if provision.verified(path, row, stamps): - save(path.with_suffix('.json'), dict(bytes=row['bytes'], sha256=row['sha256'])) + if not path.with_suffix('.json').is_file(): + save(path.with_suffix('.json'), dict(bytes=row['bytes'], sha256=row['sha256'])) continue if path.exists(): - raise ValueError('Sampling cache failed verification; existing file retained: ' + str(path)) + # A damaged or superseded copy must not block every request. + network.retain_partial(path, 'rejected') + if path.with_suffix('.json').exists(): + network.retain_partial(path.with_suffix('.json'), 'rejected') missing.append((table, row, path)) + if len(stamps) != recorded: + save(ledger, stamps) if not missing: return dict(seconds=0., downloaded_bytes=0) started = time.monotonic() total = sum(row['bytes'] for _, row, _ in missing) - def emit(done, speed=0., stage='download'): + shared.mkdir(parents=True, exist_ok=True) + free = shutil.disk_usage(shared).free + if free < total + 256 * 2**20: + raise ValueError('Not enough disk space for sampling caches: %.0f MiB needed, %.0f MiB free in %s. ' + 'Free space and retry.' % ((total + 256 * 2**20) / 2**20, free / 2**20, shared)) + lock = threading.Lock() + done, speeds, shown = [0] * len(missing), [0.] * len(missing), [0.] + def emit(stage='download', force=False): check() - progress(dict(phase='dependencies', stage=stage, label='Installing sampling cache', - done=done, total=total, unit='bytes', bytes_per_second=speed, - overall=dict(status='preparing', estimated=False, fraction=done/total, elapsed_seconds=0.))) - emit(0, stage='probe') - package = Path(__file__).parent - preferences = read(root / 'download-settings.json') - measured = preferences.get('probe', {}) - if (time.time()-measured.get('measured_at', 0) < 86400 - and measured.get('proxy_mode', 'auto') == preferences['proxy_mode']): - networking = dict(sources=measured['sources'], proxy_mode=preferences['proxy_mode']) - else: - from .environments import bootstrap_versions - networking = network.plan(json.loads((package/'dependencies.json').read_text(encoding='utf-8')), - bootstrap_versions(json.loads((package/'bootstrap_versions.json').read_text(encoding='utf-8'))), - model_only=True, measure_speed=True, proxy_mode=preferences['proxy_mode'], env=environ, - progress=lambda _: emit(0, stage='probe')) - networking['download_settings_path'] = str(root / 'download-settings.json') - networking['resource_check'] = check + with lock: + now = time.monotonic() + if not force and now - shown[0] < .25: + return + shown[0] = now + current = sum(done) + progress(dict(phase='dependencies', stage=stage, label='Installing sampling cache', + done=current, total=total, unit='bytes', bytes_per_second=sum(speeds), + overall=dict(status='preparing', estimated=False, fraction=current/total, elapsed_seconds=0.))) + # Sources ranked at setup, with the current download settings and fallback; + # no new speed test before every request. + networking = assets._download_plan(root) + networking.update(quiet=True, resource_check=check) from .prepared_model import token secret = token(environ) headers = lambda source: ['Authorization: Bearer '+secret] if secret and source == 'official' else [] - done = 0 - for table, row, path in missing: - check() + def fetch(index, table, row, path): path.parent.mkdir(parents=True, exist_ok=True) remote = table['download'] spec = dict(repo=remote['repo'], revision=remote['revision'], file=remote['prefix']+'/'+row['file']) - network.download(network.model_urls(networking, spec, environ), path, row['sha256'], - lambda current, size, speed, **_: emit(done+current, speed), network=networking, - size=row['bytes'], category='models', headers_for=headers, keep_partial=True, - stall_seconds=30, slow_seconds=15, low_speed_limit=64*1024) - assets.check_table(path, row, table['identity']) + def moved(current, size, speed, **_): + done[index], speeds[index] = current, speed or 0. + emit() + network.download(network.model_urls(networking, spec, environ), path, row['sha256'], moved, + network=networking, size=row['bytes'], category=network.model_family(spec), headers_for=headers, + keep_partial=True, stall_seconds=30, slow_seconds=15, low_speed_limit=64*1024) + # network.download already verified the SHA-256. + assets.check_table(path, row, table['identity'], verify_hash=False) save(path.with_suffix('.json'), dict(bytes=row['bytes'], sha256=row['sha256'])) - stamps[str(path)] = provision.file_identity(path, row) - save(ledger, stamps) - done += row['bytes'] - emit(done) - return dict(seconds=time.monotonic()-started, downloaded_bytes=done) + with lock: + done[index], speeds[index] = row['bytes'], 0. + stamps[str(path)] = provision.file_identity(path, row) + save(ledger, stamps) + emit(force=True) + emit(force=True) + # Each table is fifty small files; several transfers hide per-file latency. + with ThreadPoolExecutor(max_workers=min(WORKERS, len(missing)), thread_name_prefix='sampling-cache') as pool: + futures = [pool.submit(fetch, index, *item) for index, item in enumerate(missing)] + try: + for future in futures: + future.result() + except BaseException: + stop.set() + for future in futures: + future.cancel() + raise + return dict(seconds=time.monotonic()-started, downloaded_bytes=total) diff --git a/web/freevideo.js b/web/freevideo.js index c36cd4e..44678bc 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -246,12 +246,21 @@ function resultPanel(node) { node.addDOMWidget("freevideo_result", "freevideo_result", panel, {serialize: false, getMinHeight: panelHeight, getMaxHeight: panelHeight}); const connected = node.onConnectionsChange, configured = node.onConfigure; node.onConnectionsChange = function (...args) { const result = connected?.apply(this, args); warn(); queueMicrotask(syncPrompts); return result; }; + // Nodes made from now on carry the 8 + 3 default; a later choice of 8 + 2 is kept. + node.properties ??= {}; + node.properties.freevideo_refine_v3 = true; node.onConfigure = function (...args) { const result = configured?.apply(this, args); // The earlier private test put a force checkbox at this position, // before the public version added sampling-step widgets. const base = this.widgets?.find(w => w.name === 'base_steps'); if (base && typeof base.value === 'boolean') base.value = 8; + // Workflows saved with the earlier 8 + 2 default move to the 8 + 3 default once. + if (!args[0]?.properties?.freevideo_refine_v3) { + const refine = this.widgets?.find(w => w.name === 'refine_steps'); + const pass = this.widgets?.find(w => w.name === 'two_pass'); + if (refine?.value === 2 && base?.value === 8 && pass?.value !== false) refine.value = 3; + } warn(); return result; }; warn(); diff --git a/web/sampling_effort.js b/web/sampling_effort.js index f553e54..33282a8 100644 --- a/web/sampling_effort.js +++ b/web/sampling_effort.js @@ -1,11 +1,19 @@ // One editor control. The queued request remains an immutable graph snapshot. +// Each level is a complete plan: Light is two-pass 8 + 3; the others sample once. export const SAMPLING_EFFORTS = Object.freeze([ - {name: 'Light', steps: 8, color: '#90b9b2'}, - {name: 'Medium', steps: 12, color: '#8aafd3'}, - {name: 'High', steps: 16, color: '#a8a6d4'}, - {name: 'Max', steps: 20, color: '#c4ae8d'}, + {name: 'Light', zh: '轻量', steps: 8, twoPass: true, color: '#90b9b2'}, + {name: 'Medium', zh: '标准', steps: 12, twoPass: false, color: '#8aafd3'}, + {name: 'High', zh: '精细', steps: 16, twoPass: false, color: '#a8a6d4'}, + {name: 'Max', zh: '极致', steps: 20, twoPass: false, color: '#c4ae8d'}, ].map(Object.freeze)); +export const effortName = (t, tier) => t(tier.name, tier.zh); + +// The level a plan matches, or undefined for custom steps. +export function effortFor(steps, twoPass, refine) { + return SAMPLING_EFFORTS.find(tier => tier.steps === steps && tier.twoPass === twoPass && (!twoPass || refine === 3)); +} + export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { const el = (tag, cls, text) => { const e = document.createElement(tag); e.className = cls; if (text) e.textContent = text; return e; }; const element = el('div', 'fv-effort'); @@ -30,9 +38,9 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { fill.style.width = `${position / 3 * 100}%`; const index = Math.round(position), tier = SAMPLING_EFFORTS[index]; element.style.setProperty('--fv-effort-color', tier.color); - selectedLabel.textContent = tier.name; + selectedLabel.textContent = effortName(t, tier); rail.setAttribute('aria-valuenow', String(index)); - rail.setAttribute('aria-valuetext', `${tier.name}, ${tier.steps}${twoPass && tier.steps === 8 ? ' + 3' : ''} ${t('steps', '步')}`); + rail.setAttribute('aria-valuetext', `${effortName(t, tier)}, ${tier.steps}${tier.twoPass ? ' + 3' : ''} ${t('steps', '步')}`); } function position(event) { const box = rail.getBoundingClientRect(); @@ -40,23 +48,23 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { } function commit(index) { selected = index; custom.hidden = true; delete element.dataset.custom; - paint(index); onChange(SAMPLING_EFFORTS[index].steps, 3); + paint(index); onChange(SAMPLING_EFFORTS[index]); } function cancel() { const id = pointer; pointer = null; delete rail.dataset.dragging; if (id !== null && rail.hasPointerCapture(id)) rail.releasePointerCapture(id); update({baseSteps: currentSteps, refineSteps: currentRefine, twoPass, disabled}); - onPreview(currentSteps); + onPreview(null); } rail.onpointerdown = event => { if (disabled || event.button !== 0 || pointer !== null) return; event.preventDefault(); rail.focus(); pointer = event.pointerId; rail.setPointerCapture(pointer); rail.dataset.dragging = 'true'; - draft = position(event); paint(draft); onPreview(SAMPLING_EFFORTS[Math.round(draft)].steps); + draft = position(event); paint(draft); onPreview(SAMPLING_EFFORTS[Math.round(draft)]); }; rail.onpointermove = event => { if (event.pointerId !== pointer) return; - draft = position(event); paint(draft); onPreview(SAMPLING_EFFORTS[Math.round(draft)].steps); + draft = position(event); paint(draft); onPreview(SAMPLING_EFFORTS[Math.round(draft)]); }; rail.onpointerup = event => { if (event.pointerId !== pointer) return; @@ -81,7 +89,7 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { if (pointer !== null) return; const exact = SAMPLING_EFFORTS.findIndex(v => v.steps === currentSteps); selected = exact < 0 ? SAMPLING_EFFORTS.reduce((best, v, i) => Math.abs(v.steps - currentSteps) < Math.abs(SAMPLING_EFFORTS[best].steps - currentSteps) ? i : best, 0) : exact; - const isCustom = exact < 0 || (twoPass && currentRefine !== 3); + const isCustom = !effortFor(currentSteps, twoPass, currentRefine); element.dataset.custom = String(isCustom); custom.hidden = !isCustom; custom.textContent = `${t('Custom', '自定义')} · ${currentSteps}${twoPass ? ' + ' + currentRefine : ''} ${t('steps', '步')}`; paint(selected); diff --git a/web/setup.js b/web/setup.js index 4f7b514..c5c95af 100644 --- a/web/setup.js +++ b/web/setup.js @@ -39,7 +39,7 @@ export async function openSetup() { copyLabel.append(copy, node('span', t("Create independent copies (uses extra disk space)", "创建独立副本(额外占用磁盘)"))); inputs.append(copyLabel); inputs.append(note(t('Models are reused by default.', '默认直接复用已有模型。'))); const samplingLabel = node('label'); samplingLabel.className = 'fv-check'; - const samplingCaches = node('input'); samplingCaches.type = 'checkbox'; + const samplingCaches = node('input'); samplingCaches.type = 'checkbox'; samplingCaches.checked = true; const samplingText = node('span', t('Prepare all quality levels (optional)', '提前下载全部质量档位(可选)')); samplingLabel.append(samplingCaches, samplingText); inputs.append(samplingLabel); const downloads = node('section'); diff --git a/web/share.js b/web/share.js index 97a661c..5def439 100644 --- a/web/share.js +++ b/web/share.js @@ -1,6 +1,6 @@ import {api} from '../../scripts/api.js'; import {closeDialog} from './motion.js'; -import {SAMPLING_EFFORTS} from './sampling_effort.js'; +import {effortFor, effortName} from './sampling_effort.js'; const css=document.createElement('link'); css.rel='stylesheet'; css.href=new URL('./share.css',import.meta.url).href; document.head.append(css); const el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text)e.textContent=text;return e;}; @@ -34,8 +34,9 @@ export function drawShareCard(canvas,frame,logo,record,t) { fit(gpu,21,w-320);ctx.fillStyle='#a9b8ca';ctx.textAlign='right';ctx.fillText(gpu,w-pad,y+52);ctx.textAlign='left'; ctx.fillStyle='#2a3746';ctx.fillRect(pad,y+82,w-pad*2,1); const p=record.sampling_plan||{},steps=p.base_steps,refine=p.enabled?p.refine_steps:0; - const tier=SAMPLING_EFFORTS.find(r=>r.steps===steps&&(!p.enabled||(steps===8&&[2,3].includes(refine)))); - const tierName=tier?.name||(steps?t('Custom','自定义'):'—'); + // Results made with the earlier 8 + 2 default count as Light. + const tier=effortFor(steps,!!p.enabled,p.enabled&&steps===8&&refine===2?3:refine); + const tierName=tier?effortName(t,tier):(steps?t('Custom','自定义'):'—'); const unified=record.memory_model==='unified'; const items=[ [t('Generation time','生成耗时'),duration(record.request_seconds),t('Sampling ','采样 ')+duration(record.sample_seconds),'#edf3fc'], diff --git a/web/studio.js b/web/studio.js index 532a2ca..8bc8548 100644 --- a/web/studio.js +++ b/web/studio.js @@ -187,21 +187,24 @@ export function openStudio(node) { const twoPass = el('input'); twoPass.type = 'checkbox'; twoPass.checked = value(node, 'two_pass') !== false; twoPass.setAttribute('role', 'switch'); twoPass.disabled = linked(node, 'two_pass') || !widget(node, 'two_pass'); - twoPass.onchange = () => { set(node, 'two_pass', twoPass.checked); syncSamplingSteps(); }; + // Two-pass switched off only because first-pass steps left 8; returning to 8 restores it. + let autoSinglePass = false; + twoPass.onchange = () => { autoSinglePass = false; set(node, 'two_pass', twoPass.checked); syncSamplingSteps(); }; const twoPassLabel = el('label', null, 'fv-two-pass'); twoPassLabel.title = t('Generate at a lower resolution, then upscale and finish sampling at the target size to save time.', '先以低分辨率生成,再放大到目标分辨率完成采样,缩短生成时间。'); twoPass.title = twoPassLabel.title; const twoPassStatus = el('small', '', 'fv-two-pass-status'); twoPassLabel.append(el('span', t('Two-pass acceleration', '二次采样加速')), twoPassStatus, twoPass); let effortEstimate = {}, effortEstimateKey = '', effortEstimateTimer = null, effortEstimateRequest = null; - const effort = createSamplingEffort(t, {onChange: (base, refine) => { - baseSteps.value = base; - refineSteps.value = refine; - twoPass.checked = base === 8; - set(node, 'two_pass', twoPass.checked); - set(node, 'refine_steps', refine); + const effort = createSamplingEffort(t, {onChange: tier => { + baseSteps.value = tier.steps; + refineSteps.value = 3; + twoPass.checked = tier.twoPass; + autoSinglePass = false; + set(node, 'two_pass', tier.twoPass); + set(node, 'refine_steps', 3); syncSamplingSteps(); - }, onPreview: steps => showEffortEstimate(steps)}); + }, onPreview: tier => tier ? showEffortEstimate(tier.steps, tier.twoPass) : showEffortEstimate()}); const samplingSettings = el('div', null, 'fv-sampling-settings'); samplingSettings.append(effort.element, twoPassLabel); canvas.append(samplingSettings); cleanup.push(() => effort.dispose()); @@ -243,15 +246,23 @@ export function openStudio(node) { input.onchange = () => input.reportValidity(); samplingFields.append(field(label, input)); } + // Apply the two-pass rule to committed, valid values only, never while typing. + baseSteps.onchange = () => { + if (!baseSteps.reportValidity()) return; + if (!linked(node, 'two_pass') && widget(node, 'two_pass')) { + const eight = Number(baseSteps.value) === 8; + if (!eight && twoPass.checked) { twoPass.checked = false; set(node, 'two_pass', false); autoSinglePass = true; } + else if (eight && autoSinglePass) { twoPass.checked = true; set(node, 'two_pass', true); autoSinglePass = false; } + } + syncSamplingSteps(); + }; function syncSamplingSteps() { const baseLinked = linked(node, 'base_steps'); - const unavailable = Number(baseSteps.value) !== 8; - if (unavailable && !linked(node, 'two_pass')) { - twoPass.checked = false; - if (value(node, 'two_pass') !== false) set(node, 'two_pass', false); - } + // Two-pass is offered at 8 first-pass steps. A saved workflow that already + // uses it with other steps is shown as custom and left unchanged. + const unavailable = Number(baseSteps.value) !== 8 && !twoPass.checked; twoPass.disabled = unavailable || linked(node, 'two_pass') || !widget(node, 'two_pass'); - twoPassStatus.textContent = unavailable ? t('Coming soon', '待开发中') : ''; + twoPassStatus.textContent = unavailable ? t('Coming soon', '即将推出') : ''; twoPassLabel.classList.toggle('fv-two-pass-unavailable', unavailable); baseSteps.disabled = baseLinked || !widget(node, 'base_steps'); refineSteps.disabled = !twoPass.checked || linked(node, 'refine_steps') || !widget(node, 'refine_steps'); @@ -260,7 +271,7 @@ export function openStudio(node) { refineSteps.setCustomValidity(twoPass.checked && !baseLinked && !refineSteps.disabled && Number(refineSteps.value) !== 3 && Number(refineSteps.value) >= Number(baseSteps.value) ? t('Other second-pass counts must be fewer than first-pass steps.', '其他二采步数需小于一采步数。') : ''); refineSteps.title = twoPass.checked ? t('Three steps use the independent refinement schedule.', '3 步使用独立二采时间表。') - : t('Enable two-pass sampling to use this setting.', '开启二次采样后生效。'); + : t('Turn on two-pass acceleration to use this setting.', '开启二次采样加速后生效。'); for (const [input, name] of [[baseSteps, 'base_steps'], [refineSteps, 'refine_steps']]) { if (!input.disabled && input.checkValidity() && Number(input.value) !== value(node, name)) { set(node, name, Number(input.value)); @@ -272,10 +283,11 @@ export function openStudio(node) { } syncSamplingSteps(); advanced.append(samplingFields, samplingWarning); - function showEffortEstimate(steps = Number(baseSteps.value)) { - const mode = steps === 8 && (Number(baseSteps.value) !== 8 || twoPass.checked) ? 'two_pass' : 'single'; + function showEffortEstimate(steps = Number(baseSteps.value), twoPassPlan = twoPass.checked) { + const mode = twoPassPlan ? 'two_pass' : 'single'; const estimate = effortEstimate[mode]?.[steps]; - if (estimate?.status !== 'estimated' || (mode === 'two_pass' && Number(refineSteps.value) !== 3)) { + // A previewed level always refines three steps; the current plan may not. + if (estimate?.status !== 'estimated' || (mode === 'two_pass' && !arguments.length && Number(refineSteps.value) !== 3)) { effort.setEstimate('', t('Estimates appear after a matching generation on this computer.', '本机有相近生成记录后显示预计耗时。')); return; } From 6a103545a7dfa9e79da03dc044c1d2490acfb52c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 5 Oct 2026 21:50:07 +0000 Subject: [PATCH 2/7] Commit a field being edited before Ctrl+Enter, stop every cache transfer on cancel - Ctrl+Enter leaves focus in a field: commit its edit before queuing, so the queued plan matches the editor. One first-pass step is accepted once the two-pass rule has turned two-pass off. - A cancel reaches one download worker; stop the others at once instead of waiting for earlier transfers in order. - Add tables only for reused caches whose weights match the published ones. - The CLI and the ComfyUI setup panel prepare every level for new installations and keep an existing installation's choice, like the launcher. --- freevideo_engine/bootstrap.py | 18 ++++++++++++------ freevideo_engine/comfy_setup.py | 3 ++- freevideo_engine/provision.py | 4 ++-- freevideo_engine/sampling_assets.py | 24 ++++++++++++++++++------ web/setup.js | 4 +++- web/studio.js | 15 ++++++++++----- 6 files changed, 47 insertions(+), 21 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 67aca45..eb37233 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -313,10 +313,15 @@ def plan(args, *, local_progress=None): errors.append('No verified native-compatible prepared model is available. Source conversion is not supported on Mac.') files = required_models(json.loads((PACKAGE / 'model_files.json').read_text(encoding='utf-8')), reuse_cache or prepared) files += prepared_model.files(prepared) - sampling_caches = bool(getattr(args, 'sampling_caches', True)) - if prepared or reuse_cache: - # Published tables match these weights; source conversions compute their own. - from .sampling_assets import install_files + # New installations prepare every quality level; an existing one keeps its + # earlier choice (off if it predates the option) unless the flag says otherwise. + sampling_caches = getattr(args, 'sampling_caches', None) + if sampling_caches is None: + sampling_caches = prior['sampling_caches'] if isinstance(prior.get('sampling_caches'), bool) else not saved.get('ready') + sampling_caches = bool(sampling_caches) + from .sampling_assets import install_files, usable_with + if prepared or (reuse_cache and usable_with(reuse_cache)): + # Published tables match these weights; other caches compute their own. files += install_files(sampling_caches) local_reuse = None local_folder = getattr(args, 'reuse_models', None) @@ -1420,8 +1425,9 @@ def main(argv=None): parser.add_argument('--environment', choices=('unified', 'dual'), help='New installs default to unified; updates retain their saved layout. Existing environments are kept when switching.') parser.add_argument('--models', type=Path, help='Reuse/download official model files in this directory') - parser.add_argument('--sampling-caches', action=argparse.BooleanOptionalAction, default=True, - help='Install every quality level in advance (default). The default 8 + 3 refinement tables are always installed.') + parser.add_argument('--sampling-caches', action=argparse.BooleanOptionalAction, default=None, + help='Install every quality level in advance (default for new installations; existing ones keep ' + 'their choice). The default 8 + 3 refinement tables are always installed.') parser.add_argument('--model-source', choices=('prepared', 'source'), default='prepared', help='Default: download a pinned slim model matching the GPU format. source: explicitly download original weights and convert locally') parser.add_argument('--encoder-models', type=Path, help='Directory containing text_encoders/') diff --git a/freevideo_engine/comfy_setup.py b/freevideo_engine/comfy_setup.py index 639e9a3..f907cd4 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -248,7 +248,8 @@ def inspect(self, value): arguments.append('--frontend-separate') if frontend.get('download'): arguments.append('--frontend-download') - arguments.append('--no-sampling-caches' if value.get('sampling_caches') is False else '--sampling-caches') + if isinstance(value.get('sampling_caches'), bool): + arguments.append('--sampling-caches' if value['sampling_caches'] else '--no-sampling-caches') if value.get('copy'): arguments.append('--copy-existing-models') self.selection = dict(root=str(root), arguments=arguments) diff --git a/freevideo_engine/provision.py b/freevideo_engine/provision.py index 23f4ec7..4ec2470 100644 --- a/freevideo_engine/provision.py +++ b/freevideo_engine/provision.py @@ -70,8 +70,8 @@ def models(plan): prepared = plan.get('prepared_model') files = list(required_models(json.loads((PACKAGE / 'model_files.json').read_text(encoding='utf-8')), plan.get('reuse_cache') or prepared)) files += prepared_model.files(prepared) - if prepared or plan.get('reuse_cache'): - from .sampling_assets import install_files + from .sampling_assets import install_files, usable_with + if prepared or (plan.get('reuse_cache') and usable_with(plan['reuse_cache'])): files += install_files(bool(plan.get('sampling_caches'))) mode = plan.get('verification', 'auto') from .local_models import key as local_key, import_file diff --git a/freevideo_engine/sampling_assets.py b/freevideo_engine/sampling_assets.py index d43b62e..8d34c8e 100644 --- a/freevideo_engine/sampling_assets.py +++ b/freevideo_engine/sampling_assets.py @@ -43,6 +43,17 @@ def files(selected=None): return result +def usable_with(cache): + """Published tables match prepared weights; a locally converted cache may not.""" + from . import adaln_assets as assets + try: + manifest = json.loads((Path(cache) / 'manifest.json').read_text(encoding='utf-8')) + weights = assets.weight_identity(manifest) + except (OSError, ValueError, KeyError, TypeError): + return False + return any(table['identity']['weights'] == weights for table in tables()) + + def install_files(everything): """Setup always installs the refinement tables; the option adds every level.""" return files(None if everything else [t for t in tables() if required(t)]) @@ -87,7 +98,7 @@ def engine_task(media, base=None): def prepare(root, machine, sampling, task, *, progress, interrupted=None, environ=None): """Fetch only this request's missing tables before starting its timer.""" - from concurrent.futures import ThreadPoolExecutor + from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, wait import shutil import threading from . import adaln_assets as assets, network, provision @@ -187,12 +198,13 @@ def moved(current, size, speed, **_): # Each table is fifty small files; several transfers hide per-file latency. with ThreadPoolExecutor(max_workers=min(WORKERS, len(missing)), thread_name_prefix='sampling-cache') as pool: futures = [pool.submit(fetch, index, *item) for index, item in enumerate(missing)] - try: - for future in futures: - future.result() - except BaseException: + # A cancel reaches one worker (ComfyUI clears its flag once raised): stop + # the rest at once instead of waiting for earlier transfers to finish. + done, _ = wait(futures, return_when=FIRST_EXCEPTION) + failed = next((future for future in futures if future in done and future.exception()), None) + if failed is not None: stop.set() for future in futures: future.cancel() - raise + raise failed.exception() return dict(seconds=time.monotonic()-started, downloaded_bytes=total) diff --git a/web/setup.js b/web/setup.js index c5c95af..8aa74dd 100644 --- a/web/setup.js +++ b/web/setup.js @@ -39,7 +39,7 @@ export async function openSetup() { copyLabel.append(copy, node('span', t("Create independent copies (uses extra disk space)", "创建独立副本(额外占用磁盘)"))); inputs.append(copyLabel); inputs.append(note(t('Models are reused by default.', '默认直接复用已有模型。'))); const samplingLabel = node('label'); samplingLabel.className = 'fv-check'; - const samplingCaches = node('input'); samplingCaches.type = 'checkbox'; samplingCaches.checked = true; + const samplingCaches = node('input'); samplingCaches.type = 'checkbox'; const samplingText = node('span', t('Prepare all quality levels (optional)', '提前下载全部质量档位(可选)')); samplingLabel.append(samplingCaches, samplingText); inputs.append(samplingLabel); const downloads = node('section'); @@ -319,6 +319,8 @@ export async function openSetup() { showResources(info.discovery.resources); samplingText.textContent = t('Prepare all quality levels (optional)', '提前下载全部质量档位(可选)') + (info.discovery.sampling_cache_bytes ? ' · ' + gib(info.discovery.sampling_cache_bytes) : ''); + // New installations prepare every quality level; a ready one adds them only when chosen. + samplingCaches.checked = !info.discovery.ready; paths.textContent = info.discovery.libraries.join("\n") || t("No model libraries found; add a folder above.", "没有发现模型目录,可在上方添加。"); render(info.task); installation.open = !info.discovery.ready || busy; diff --git a/web/studio.js b/web/studio.js index 8bc8548..173ea85 100644 --- a/web/studio.js +++ b/web/studio.js @@ -248,13 +248,15 @@ export function openStudio(node) { } // Apply the two-pass rule to committed, valid values only, never while typing. baseSteps.onchange = () => { - if (!baseSteps.reportValidity()) return; - if (!linked(node, 'two_pass') && widget(node, 'two_pass')) { - const eight = Number(baseSteps.value) === 8; - if (!eight && twoPass.checked) { twoPass.checked = false; set(node, 'two_pass', false); autoSinglePass = true; } - else if (eight && autoSinglePass) { twoPass.checked = true; set(node, 'two_pass', true); autoSinglePass = false; } + // Before validation: one step is valid only once two-pass is off. + const steps = Number(baseSteps.value); + if (baseSteps.value !== '' && Number.isInteger(steps) && steps >= 1 && steps <= 32 + && !linked(node, 'two_pass') && widget(node, 'two_pass')) { + if (steps !== 8 && twoPass.checked) { twoPass.checked = false; set(node, 'two_pass', false); autoSinglePass = true; } + else if (steps === 8 && autoSinglePass) { twoPass.checked = true; set(node, 'two_pass', true); autoSinglePass = false; } } syncSamplingSteps(); + baseSteps.reportValidity(); }; function syncSamplingSteps() { const baseLinked = linked(node, 'base_steps'); @@ -581,6 +583,9 @@ export function openStudio(node) { } async function submitDraft() { if (capturing || queueState?.submitting) return; + // Ctrl+Enter leaves focus in a field: commit its edit (change fires on blur) first. + const editing = dialog.contains(document.activeElement) ? document.activeElement : null; + if (editing && editing !== generate) { editing.blur(); editing.focus({preventScroll: true}); } if ([...dialog.querySelectorAll('input:not(:disabled)')].some(e => !e.reportValidity())) return; if (!prompt.disabled && !prompt.value.trim()) { prompt.focus(); status.textContent = t('Describe your scene first.', '请先描述画面。'); return; } const chosenMode = runMode, batchCount = chosenMode === 'batch' ? Number(count.value) : 1; From 1945b40f3fb3da9738c76544eb3c15cb76fce7c4 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 5 Oct 2026 21:59:07 +0000 Subject: [PATCH 3/7] Download sampling tables in parallel during setup Every quality level is nine hundred small files. Setup sent each through a large-model transfer process, two at a time, so the tables took about half an hour on a fast connection. Fetch them six at a time with the request preflight's verified resumable transfers; receipts, the ledger and progress are unchanged, and large model files keep their two-slot pipeline. --- freevideo_engine/provision.py | 50 ++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/freevideo_engine/provision.py b/freevideo_engine/provision.py index 4ec2470..0583dc3 100644 --- a/freevideo_engine/provision.py +++ b/freevideo_engine/provision.py @@ -231,6 +231,48 @@ def transfer_complete(): # final inode stamp without a second pass through large model files. complete(row, path, dict(file_identity(path, row), method='full_content_hash_resumable_download'), 'download', 'download') + def download_small(entries): + # Sampling tables are hundreds of small files: fetch them in parallel + # with the request preflight's verified resumable transfers, instead + # of one transfer process per file. + from concurrent.futures import FIRST_EXCEPTION, wait + def fetch(entry): + row, path, hf_directory, _ = entry + check() + if path.exists() or path.is_symlink(): + prior = {} + if not verified(path, row, prior, mode=mode, hf_directory=hf_directory): + raise ValueError('Model destination appeared with unexpected content: ' + str(path)) + complete(row, path, prior[str(path)], 'verify') + return + emit('download_model', file=str(path), bytes=row['bytes']) + model_progress.update(row, phase='downloading') + path.parent.mkdir(parents=True, exist_ok=True) + last = [0.] + def progress(done, size, speed, **_): + check() + if time.monotonic() - last[0] >= .5: + model_progress.update(row, phase='downloading', done=done, rate=speed) + last[0] = time.monotonic() + network.download(network.model_urls(networking, row), path, row['sha256'], progress, + network=dict(networking, quiet=True), size=row['bytes'], + category=network.model_family(row), headers_for=model_headers, + keep_partial=True, stall_seconds=30, slow_seconds=15, low_speed_limit=64 * 1024) + complete(row, path, dict(file_identity(path, row), method='full_content_hash_resumable_download'), + 'download', 'download') + with ThreadPoolExecutor(max_workers=min(6, len(entries)), thread_name_prefix='sampling-download') as small_pool: + started = [small_pool.submit(fetch, entry) for entry in entries] + done, _ = wait(started, return_when=FIRST_EXCEPTION) + failed = next((future for future in started if future in done and future.exception()), None) + if failed is not None: + stopped.set() + for future in started: + future.cancel() + raise failed.exception() + small = [entry for entry in missing if entry[0].get('sampling_file')] + large = [entry for entry in missing if not entry[0].get('sampling_file')] + if small: + download_small(small) # Explicitly preserve the old one-file behavior for plans without the # new policy field. When enabled, use a two-slot pipeline: one child # can finish its pinned hash while the next child transfers. The @@ -238,14 +280,14 @@ def transfer_complete(): # current body has ended, preventing multiple large network bodies # from being started at once. if workers == 1: - for entry in missing: + for entry in large: download_one(entry) return # Do not turn this into an unbounded file pool: each transfer child # has its own native range buffers and process guard. Two slots are # enough to overlap network I/O with the previous file's final hash. condition = threading.Condition() - entries = iter(missing) + entries = iter(large) futures, state = set(), {'active': 0, 'finished': 0, 'error': None} pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix='model-download') @@ -284,14 +326,14 @@ def finished(future): # Files without a transport callback (a copied-in file or a # tiny fallback transfer) still advance the pipeline after # they finish. - if error is None and state['active'] == 0 and state['finished'] < len(missing): + if error is None and state['active'] == 0 and state['finished'] < len(large): submit_next() condition.notify_all() try: submit_next() with condition: - while state['finished'] < len(missing) and state['error'] is None: + while state['finished'] < len(large) and state['error'] is None: condition.wait(.2) if state['error'] is not None: raise state['error'] From 38c21f42450546bea3f9ba6a62c070de32a1538e Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 5 Oct 2026 22:00:58 +0000 Subject: [PATCH 4/7] Keep the launcher's DLL directory when several downloads start at once The frozen launcher clears PyInstaller's DLL directory while it starts an external process. Parallel source probes overlapped those windows and could restore an empty directory, breaking later DLL loads in the launcher. Clear it for the first caller and restore it after the last. --- freevideo_engine/windows_ux.py | 38 +++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/freevideo_engine/windows_ux.py b/freevideo_engine/windows_ux.py index aeadd54..ec9657c 100644 --- a/freevideo_engine/windows_ux.py +++ b/freevideo_engine/windows_ux.py @@ -1,6 +1,7 @@ """Small Windows desktop helpers; no admin rights or persistent settings.""" from contextlib import contextmanager import sys +import threading from .system import windows @@ -36,21 +37,38 @@ def hidden_console(): return dict(startupinfo=info) +_DLL_LOCK = threading.Lock() +_DLL_USERS = 0 +_DLL_PREVIOUS = None + + @contextmanager def external_python(): - """Keep PyInstaller's private DLL search directory out of external Python.""" - previous = None - if windows() and getattr(sys, 'frozen', False): - import ctypes - buffer = ctypes.create_unicode_buffer(32768) - ctypes.windll.kernel32.GetDllDirectoryW(len(buffer), buffer) - previous = buffer.value - ctypes.windll.kernel32.SetDllDirectoryW(None) + """Keep PyInstaller's private DLL search directory out of external Python. + + The directory is process-wide and parallel source probes start several curl + processes at once: clear it for the first user and restore it after the last, + so overlapping callers cannot leave the launcher without its DLL directory. + """ + global _DLL_USERS, _DLL_PREVIOUS + if not (windows() and getattr(sys, 'frozen', False)): + yield + return + import ctypes + with _DLL_LOCK: + if not _DLL_USERS: + buffer = ctypes.create_unicode_buffer(32768) + ctypes.windll.kernel32.GetDllDirectoryW(len(buffer), buffer) + _DLL_PREVIOUS = buffer.value + ctypes.windll.kernel32.SetDllDirectoryW(None) + _DLL_USERS += 1 try: yield finally: - if previous is not None: - ctypes.windll.kernel32.SetDllDirectoryW(previous) + with _DLL_LOCK: + _DLL_USERS -= 1 + if not _DLL_USERS: + ctypes.windll.kernel32.SetDllDirectoryW(_DLL_PREVIOUS or None) @contextmanager From 75bd5e931167cc12e3539493f13ff3cfd97ea13a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 5 Oct 2026 22:07:33 +0000 Subject: [PATCH 5/7] Read sampling tables and the upscaler from the folder setup filled Engine processes receive FREEVIDEO_HOME only, so paths.model_root() meant models/ while setup and the request preflight write to the recorded model folder (models/vdn). The engine therefore never used installed or preflighted sampling tables and downloaded each one again during the timed run, and new installations downloaded the 690 MB latent upscaler a second time on their first two-pass generation. Resolve the recorded folder for both; a copy an earlier version downloaded under models/ stays in use. --- freevideo_engine/adaln.py | 4 ++-- freevideo_engine/paths.py | 16 ++++++++++++++++ freevideo_engine/two_pass.py | 7 +++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/freevideo_engine/adaln.py b/freevideo_engine/adaln.py index 85576ca..739e29b 100644 --- a/freevideo_engine/adaln.py +++ b/freevideo_engine/adaln.py @@ -67,9 +67,9 @@ def __init__(self, root, source_id, steps, task='t2va', *, manifest=None, self.shared = None if self.asset is None: # Published tables installed by setup or the request preflight. - from .paths import model_root + from .paths import installed_model_root from .sampling_assets import cache_root - self.shared = cache_root(model_root()) / assets.directory(self.identity) + self.shared = cache_root(installed_model_root()) / assets.directory(self.identity) if self.asset is not None: if self.asset['directory'] != self.root.name: raise ValueError('AdaLN asset identity/path mismatch') diff --git a/freevideo_engine/paths.py b/freevideo_engine/paths.py index 8db2e48..f5bdbe5 100644 --- a/freevideo_engine/paths.py +++ b/freevideo_engine/paths.py @@ -17,6 +17,22 @@ def model_root(): return Path(os.environ.get('FREEVIDEO_MODEL_ROOT', data_root() / 'models')).expanduser() +def installed_model_root(): + """The model folder setup recorded (models/vdn), unless explicitly overridden. + + Engine processes only receive FREEVIDEO_HOME, where model_root() means + models/. Files setup downloads, such as the latent upscaler and sampling + tables, live in the recorded folder. + """ + if not os.environ.get('FREEVIDEO_MODEL_ROOT'): + import json + try: + return Path(json.loads((data_root() / 'machine.json').read_text(encoding='utf-8'))['model_root']).expanduser() + except (OSError, ValueError, KeyError, TypeError): + pass + return model_root() + + def add_vdn(): root = vdn_root().resolve() if not (root / 'src/models/hybrid_attention.py').is_file(): diff --git a/freevideo_engine/two_pass.py b/freevideo_engine/two_pass.py index df8b681..f7f4914 100644 --- a/freevideo_engine/two_pass.py +++ b/freevideo_engine/two_pass.py @@ -121,8 +121,11 @@ def same_strategy(left, right): def checkpoint_path(): - from .paths import model_root - return model_root() / 'latent_upscaler' / Path(UPSCALER['file']).name + """Setup's copy; an older lazily downloaded copy under models/ stays in use.""" + from .paths import installed_model_root, model_root + installed = installed_model_root() / 'latent_upscaler' / Path(UPSCALER['file']).name + earlier = model_root() / 'latent_upscaler' / installed.name + return earlier if earlier.is_file() and not installed.is_file() else installed def ensure_checkpoint(): From cd2f566c314efad136735e6e742208c7e20ccb8c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 5 Oct 2026 22:14:53 +0000 Subject: [PATCH 6/7] Word the step rule comment and singular step labels accurately The two-pass rule runs on committed values, before validation; a single custom step reads 1 step, not 1 steps. --- web/sampling_effort.js | 3 ++- web/studio.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/sampling_effort.js b/web/sampling_effort.js index 33282a8..f21e1c0 100644 --- a/web/sampling_effort.js +++ b/web/sampling_effort.js @@ -91,7 +91,8 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { selected = exact < 0 ? SAMPLING_EFFORTS.reduce((best, v, i) => Math.abs(v.steps - currentSteps) < Math.abs(SAMPLING_EFFORTS[best].steps - currentSteps) ? i : best, 0) : exact; const isCustom = !effortFor(currentSteps, twoPass, currentRefine); element.dataset.custom = String(isCustom); custom.hidden = !isCustom; - custom.textContent = `${t('Custom', '自定义')} · ${currentSteps}${twoPass ? ' + ' + currentRefine : ''} ${t('steps', '步')}`; + const unit = currentSteps === 1 && !twoPass ? t('step', '步') : t('steps', '步'); + custom.textContent = `${t('Custom', '自定义')} · ${currentSteps}${twoPass ? ' + ' + currentRefine : ''} ${unit}`; paint(selected); if (isCustom) { rail.setAttribute('aria-valuetext', custom.textContent); selectedLabel.textContent=t('Custom','自定义'); } rail.title = disabled ? t('Controlled by connected nodes.', '由连接的节点控制。') diff --git a/web/studio.js b/web/studio.js index 173ea85..ba7e38a 100644 --- a/web/studio.js +++ b/web/studio.js @@ -246,7 +246,7 @@ export function openStudio(node) { input.onchange = () => input.reportValidity(); samplingFields.append(field(label, input)); } - // Apply the two-pass rule to committed, valid values only, never while typing. + // Apply the two-pass rule to committed values, never while typing. baseSteps.onchange = () => { // Before validation: one step is valid only once two-pass is off. const steps = Number(baseSteps.value); From edee0c21e2d0a681feaf6c3832dbaf741370d60b Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 5 Oct 2026 22:38:50 +0000 Subject: [PATCH 7/7] Launch directly when every requested quality level is installed With "Prepare all quality levels" on, the launcher treated the installation as unfinished and sent every update or launch back to the setup review, asking to accept a 0 GiB plan again. New installations now have the option on by default, so check the files instead: set up again only while a requested level is missing (or no published table applies to these weights). --- freevideo_engine/comfy_launcher_runtime.py | 10 ++++++---- freevideo_engine/sampling_assets.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index b9e70f6..a99487a 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -492,11 +492,13 @@ def _inspect(self, values): self.setup.runner.token = validate(values.get('token', '')) url = local_url(values.get('url', '')) ready = False - if not values.get('repair') and not values.get('sampling_caches'): + if not values.get('repair'): try: - installation(source, {'FREEVIDEO_HOME': str(engine)}) - ready = True - except (OSError, ValueError): + _, machine = installation(source, {'FREEVIDEO_HOME': str(engine)}) + # With every quality level requested, set up again only while some are missing. + from .sampling_assets import installed as sampling_installed + ready = not values.get('sampling_caches') or sampling_installed(machine) + except (OSError, ValueError, KeyError): pass self.selection = dict(descriptor, engine=str(engine), source=str(source), url=url, ready=ready) self.state = dict(self.state, selection=dict(self.selection), host=host) diff --git a/freevideo_engine/sampling_assets.py b/freevideo_engine/sampling_assets.py index 8d34c8e..fd0f8ff 100644 --- a/freevideo_engine/sampling_assets.py +++ b/freevideo_engine/sampling_assets.py @@ -54,6 +54,17 @@ def usable_with(cache): return any(table['identity']['weights'] == weights for table in tables()) +def installed(machine): + """Every quality level is on disk, or none applies to these weights.""" + if not usable_with(machine['cache']): + return True + folders = (cache_root(machine['model_root']), Path(machine['cache'])) + def present(row): + return any((folder / row['sampling_file']).is_file() + and (folder / row['sampling_file']).stat().st_size == row['bytes'] for folder in folders) + return all(present(row) for row in files()) + + def install_files(everything): """Setup always installs the refinement tables; the option adds every level.""" return files(None if everything else [t for t in tables() if required(t)])