diff --git a/freevideo_engine/adaln.py b/freevideo_engine/adaln.py index c0381fe..739e29b 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: - from .paths import model_root + # Published tables installed by setup or the request preflight. + from .paths import installed_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(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') @@ -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..eb37233 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -313,10 +313,16 @@ 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() + # 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) local_manifest = getattr(args, 'reuse_models_manifest', None) @@ -1419,7 +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='store_true', help='Install all four quality levels and reference-mode sampling caches in advance') + 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_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_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/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..f907cd4 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -248,8 +248,8 @@ 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') + 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/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/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/provision.py b/freevideo_engine/provision.py index a165b69..0583dc3 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() + 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 local_files, missing = [], [] @@ -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'] 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..fd0f8ff 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,78 @@ 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 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)]) + + @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 FIRST_EXCEPTION, ThreadPoolExecutor, wait + 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 +122,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 +131,91 @@ 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)] + # 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 failed.exception() + return dict(seconds=time.monotonic()-started, downloaded_bytes=total) 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(): 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 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..f21e1c0 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,9 +89,10 @@ 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', '步')}`; + 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/setup.js b/web/setup.js index 4f7b514..8aa74dd 100644 --- a/web/setup.js +++ b/web/setup.js @@ -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/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..ba7e38a 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,25 @@ export function openStudio(node) { input.onchange = () => input.reportValidity(); samplingFields.append(field(label, input)); } + // 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); + 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'); - 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 +273,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 +285,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; } @@ -569,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;