From 2fdf5d43a9082eabaf55ca9e1d8c7e413e2640ff Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:44:58 +0000 Subject: [PATCH 01/53] Image model loading shows its progress on the bar --- freevideo_engine/image_gen/image_runner.py | 67 ++++++++++++++++--- freevideo_engine/image_gen/image_stream.py | 6 +- freevideo_engine/image_gen/load_progress.py | 27 ++++++++ freevideo_engine/image_gen/qwen_te_weights.py | 25 +++++-- freevideo_engine/image_gen/qwen_weights.py | 19 +++++- freevideo_engine/image_gen/service.py | 2 + freevideo_engine/offload.py | 4 +- 7 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 freevideo_engine/image_gen/load_progress.py diff --git a/freevideo_engine/image_gen/image_runner.py b/freevideo_engine/image_gen/image_runner.py index 593a0dbd..563f2967 100644 --- a/freevideo_engine/image_gen/image_runner.py +++ b/freevideo_engine/image_gen/image_runner.py @@ -8,6 +8,7 @@ only for the stage that needs them. """ import time +import math from pathlib import Path import torch @@ -19,6 +20,36 @@ _DIT_FILE = 'diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors' _TE_FILE = 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' _VAE_FILE = 'vae/qwen_image_2.1_vae_bf16.safetensors' +# Rounded mean stage times from the local cold/hot constructor comparison. +# See benchmarks/image_load_progress_045afix/summary.txt (small-model fallback). +_LOAD_SHARES = dict(dit=.42, vae=.03, text=.18, pin=.37) + + +class _LoadProgress: + def __init__(self, callback): + self.callback = callback + self.fraction = self.emitted = 0. + self.callback('load', 0.) + self.last_time = time.monotonic() + + def report(self, stage, done, total): + offset = 0. + for name, share in _LOAD_SHARES.items(): + if name == stage: + fraction = offset + share * (done / total if total else 0.) + break + offset += share + self.fraction = max(self.fraction, min(fraction, math.nextafter(1., 0.))) + now = time.monotonic() + if self.fraction > self.emitted and now - self.last_time >= .25: + self.callback('load', self.fraction) + self.emitted, self.last_time = self.fraction, now + + def complete(self, stage): + self.report(stage, 1, 1) + + def finish(self): + self.callback('load', 1.) def _non_block_children(module, skip): @@ -86,24 +117,27 @@ def path(directory, name): nested = self.pack_dir / directory / name return nested if nested.exists() else self.pack_dir / name - progress = progress or (lambda stage, fraction: None) - progress('load', 0.) + loading = _LoadProgress(progress or (lambda stage, fraction: None)) started = time.monotonic() - self.transformer = load_dit(self.pack_dir / _DIT_FILE, device='cpu') + self.transformer = load_dit(self.pack_dir / _DIT_FILE, device='cpu', + progress=lambda done, total: loading.report('dit', done, total)) self.load_seconds['dit'] = time.monotonic() - started - progress('load', 1 / 3) + loading.complete('dit') started = time.monotonic() - self.vae = load_vae(self.pack_dir / _VAE_FILE, device='cpu') + self.vae = load_vae(self.pack_dir / _VAE_FILE, device='cpu', + progress=lambda done, total: loading.report('vae', done, total)) self.load_seconds['vae'] = time.monotonic() - started - progress('load', 2 / 3) + loading.complete('vae') started = time.monotonic() config_dir = self.pack_dir / 'text_encoder' processor_dir = self.pack_dir / 'processor' - text = load_text_encoder(self.pack_dir / _TE_FILE, config_path=config_dir / 'config.json', device='cpu') + text = load_text_encoder(self.pack_dir / _TE_FILE, config_path=config_dir / 'config.json', device='cpu', + progress=lambda done, total: loading.report('text', done, total)) from transformers import Qwen3VLProcessor processor = Qwen3VLProcessor.from_pretrained(str(processor_dir), local_files_only=True) self.load_seconds['text'] = time.monotonic() - started self.text_model = text + loading.complete('text') # Parts that always sit on the card: everything of the DiT except its blocks, and the # encoder's final norm and rotary table. The 1.2 GB vocabulary stays on the host and is @@ -119,10 +153,23 @@ def path(directory, name): self._embed_hooks = self._lookup_on_host(language.embed_tokens) started = time.monotonic() + from ..offload import cpu_weights + stacks = [self.transformer.transformer_blocks, language.layers] + stack_bytes = [sum(value.numel() * value.element_size() for layer in layers + for _, value in cpu_weights(layer)) for layers in stacks] + pin_total = sum(stack_bytes) + + def pin_progress(index): + offset = sum(stack_bytes[:index]) + return lambda done, total: loading.report( + 'pin', offset + stack_bytes[index] * (done / total if total else 0.), pin_total) + with torch.no_grad(): # LayerOffloader refuses to be built with autograd on - self.dit_stack = StreamedStack(self.transformer.transformer_blocks, name='dit', device=self.device) - self.te_stack = StreamedStack(language.layers, name='text_encoder', device=self.device) + self.dit_stack = StreamedStack(stacks[0], name='dit', device=self.device, progress=pin_progress(0)) + loading.report('pin', stack_bytes[0], pin_total) + self.te_stack = StreamedStack(stacks[1], name='text_encoder', device=self.device, progress=pin_progress(1)) self.load_seconds['pin'] = time.monotonic() - started + loading.complete('pin') self.shape = image_policy.ImageShape.measure( list(self.transformer.transformer_blocks), list(language.layers), @@ -133,7 +180,7 @@ def path(directory, name): from .qwen_pipeline import load_sample_sigmas self.generator.sample_sigmas = load_sample_sigmas(self.pack_dir / _DIT_FILE) self.last_plan = None - progress('load', 1.) + loading.finish() def _lookup_on_host(self, embedding): device = self.device diff --git a/freevideo_engine/image_gen/image_stream.py b/freevideo_engine/image_gen/image_stream.py index 7b783a5f..ee8db9bb 100644 --- a/freevideo_engine/image_gen/image_stream.py +++ b/freevideo_engine/image_gen/image_stream.py @@ -16,7 +16,8 @@ class StreamedStack: """``layers`` hold frozen CPU weights; they are moved to the card on use.""" - def __init__(self, layers, *, name, device='cuda', pin=True, max_pin_bytes=None, prefetch=True): + def __init__(self, layers, *, name, device='cuda', pin=True, max_pin_bytes=None, prefetch=True, + progress=None): self.name = name self.layers = list(layers) if not self.layers: @@ -28,7 +29,8 @@ def __init__(self, layers, *, name, device='cuda', pin=True, max_pin_bytes=None, # Pinning is an optimization: a host that refuses it costs transfer # speed, not the request (pin_layer_weights keeps the unpinned layers # on their ordinary storage and the offloader stages them). - self.pinned_bytes = pin_layer_weights(self.layers, max_bytes=max_pin_bytes) if pin else 0 + pin_options = {'progress': progress} if progress is not None else {} + self.pinned_bytes = pin_layer_weights(self.layers, max_bytes=max_pin_bytes, **pin_options) if pin else 0 self.offloader = LayerOffloader(self.layers, device=device, prefetch=prefetch, host_prefetch=False) self.layer_bytes = max(sum(count * parameter.element_size() for parameter, _, _, _, count, _ in entries) diff --git a/freevideo_engine/image_gen/load_progress.py b/freevideo_engine/image_gen/load_progress.py new file mode 100644 index 00000000..f33b8bf6 --- /dev/null +++ b/freevideo_engine/image_gen/load_progress.py @@ -0,0 +1,27 @@ +"""Byte accounting for completed safetensor reads, without opening tensor data.""" +from math import prod + + +_DTYPE_BYTES = {'BOOL': 1, 'U8': 1, 'I8': 1, 'I16': 2, 'I32': 4, 'I64': 8, + 'F16': 2, 'BF16': 2, 'F32': 4, 'F64': 8, + 'F8_E4M3': 1, 'F8_E5M2': 1} + + +class TensorProgress: + def __init__(self, checkpoint, callback, keys=None): + self.callback = callback + self.sizes = {} + for key in checkpoint.keys() if keys is None else keys: + value = checkpoint.get_slice(key) + self.sizes[key] = prod(value.get_shape()) * _DTYPE_BYTES[value.get_dtype()] + self.total = sum(self.sizes.values()) + self.done = self.last = 0 + + def tensors(self, *keys): + self.advance(sum(self.sizes[key] for key in keys)) + + def advance(self, size): + self.done += size + if self.done > self.last and ((self.done - self.last) * 50 >= self.total or self.done == self.total): + self.last = self.done + self.callback(self.done, self.total) diff --git a/freevideo_engine/image_gen/qwen_te_weights.py b/freevideo_engine/image_gen/qwen_te_weights.py index abfb066d..86d62994 100644 --- a/freevideo_engine/image_gen/qwen_te_weights.py +++ b/freevideo_engine/image_gen/qwen_te_weights.py @@ -14,6 +14,7 @@ import torch from safetensors import safe_open +from .load_progress import TensorProgress _CONVROT = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": 256} @@ -41,11 +42,13 @@ def _row_scales(st, name, rows): return scales -def _dequantize_vocabulary(st, name, shape, dtype, chunk_rows): +def _dequantize_vocabulary(st, name, shape, dtype, chunk_rows, reads=None): weight = st.get_slice(name + ".weight") if list(weight.get_shape()) != list(shape) or weight.get_dtype() != "I8" or shape[1] % 256: raise ValueError(f"Expected group-256 int8 vocabulary for {name}") scales = _row_scales(st, name, shape[0]) + if reads is not None: + reads.tensors(name + ".weight_scale") # Exactly int8_ops.hadamard16: the regular symmetric, orthogonal H256, # rather than the Sylvester Hadamard convention used by some quantizers. h4 = torch.tensor([[1, 1, 1, -1], [1, 1, -1, 1], [1, -1, 1, 1], [-1, 1, 1, 1]], dtype=torch.float32) @@ -55,6 +58,8 @@ def _dequantize_vocabulary(st, name, shape, dtype, chunk_rows): end = min(start + chunk_rows, shape[0]) rotated = (weight[start:end].float() * scales[start:end, None]).reshape(-1, shape[1] // 256, 16, 16) full[start:end] = (h16 @ rotated @ h16).reshape(-1, shape[1]).to(dtype) + if reads is not None: + reads.advance((end - start) * shape[1]) return full @@ -92,7 +97,7 @@ def _assign_tensor(model, name, value, dtype): module._buffers[leaf] = value -def _install_text_weights(model, st, *, dtype, chunk_rows): +def _install_text_weights(model, st, *, dtype, chunk_rows, reads=None): keys = set(st.keys()) handled = set() for key in sorted(k for k in keys if k.endswith(".comfy_quant")): @@ -100,6 +105,8 @@ def _install_text_weights(model, st, *, dtype, chunk_rows): if name.startswith("model.visual.") or name == "lm_head": continue _quant_metadata(st, name) + if reads is not None: + reads.tensors(key) full_name = _text_name(model, name) module = model.get_submodule(full_name) shape = module.weight.shape if isinstance(module, (torch.nn.Linear, torch.nn.Embedding)) else () @@ -107,7 +114,7 @@ def _install_text_weights(model, st, *, dtype, chunk_rows): raise ValueError(f"Quantized target is not Linear/Embedding: {name}") if isinstance(module, torch.nn.Embedding): module.weight = torch.nn.Parameter( - _dequantize_vocabulary(st, name, shape, dtype, chunk_rows), requires_grad=False + _dequantize_vocabulary(st, name, shape, dtype, chunk_rows, reads), requires_grad=False ) else: from ..int8_ops import Int8Linear @@ -126,6 +133,8 @@ def _install_text_weights(model, st, *, dtype, chunk_rows): replacement.input_dtype = dtype parent, _, child = full_name.rpartition(".") setattr(model.get_submodule(parent), child, replacement) + if reads is not None: + reads.tensors(name + ".weight", name + ".weight_scale") handled.update({key, name + ".weight", name + ".weight_scale"}) for key in sorted(keys - handled): @@ -141,13 +150,15 @@ def _install_text_weights(model, st, *, dtype, chunk_rows): if leaf == "bias" and hasattr(module, "weight_int8"): full_name += ".original" _assign_tensor(model, full_name + "." + leaf, st.get_tensor(key), dtype) + if reads is not None: + reads.tensors(key) missing = [name for name, value in model.state_dict().items() if value.is_meta] if missing: raise ValueError("Missing text encoder tensors: " + ", ".join(missing)) def load_text_encoder(path, *, config=None, config_path=None, device="cpu", dtype=torch.bfloat16, - chunk_rows=8192): + chunk_rows=8192, progress=None): """Return a Qwen3VLForConditionalGeneration with int8 language projections. ``config`` accepts a transformers Qwen3VLConfig or its dictionary. Otherwise @@ -156,6 +167,8 @@ def load_text_encoder(path, *, config=None, config_path=None, device="cpu", dtyp ``load_vision(device='cpu')`` method installs only BF16 visual tensors and returns that module, allowing the caller's placement policy to own moves. CPU supports loading/inspection; Int8Linear execution requires CUDA. + ``progress(done_bytes, total_bytes)`` counts language source bytes, including + vocabulary chunks, and excludes the deferred vision tensors and logits head. """ from accelerate import init_empty_weights from transformers import Qwen3VLConfig, Qwen3VLForConditionalGeneration @@ -181,7 +194,9 @@ def load_text_encoder(path, *, config=None, config_path=None, device="cpu", dtyp model.model.visual = None model.lm_head = torch.nn.Identity() with safe_open(str(path), framework="pt", device="cpu") as st: - _install_text_weights(model, st, dtype=dtype, chunk_rows=chunk_rows) + reads = (TensorProgress(st, progress, (key for key in st.keys() + if not key.startswith(("model.visual.", "lm_head.")))) if progress is not None else None) + _install_text_weights(model, st, dtype=dtype, chunk_rows=chunk_rows, reads=reads) model.eval().requires_grad_(False).to(device=device) model_ref = weakref.ref(model) diff --git a/freevideo_engine/image_gen/qwen_weights.py b/freevideo_engine/image_gen/qwen_weights.py index 36ce06bb..662d12c9 100644 --- a/freevideo_engine/image_gen/qwen_weights.py +++ b/freevideo_engine/image_gen/qwen_weights.py @@ -13,6 +13,7 @@ from ..streamed_weights import _open_safetensors from .qwen_dit import QwenImageDiT from .qwen_vae import QwenImageVAE, comfy_vae_key_to_diffusers, vae_key_map +from .load_progress import TensorProgress CONVROT_METADATA = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": 256} @@ -91,14 +92,16 @@ def _quant_targets(model, name): return targets, modules -def load_dit(path, device="cpu", *, config=None): +def load_dit(path, device="cpu", *, config=None, progress=None): """Load a Comfy Turbo int8_convrot DiT; CPU supports storage, CUDA execution. The optional config override is for small models/tests. Production files without JSON config metadata use the official 32-block Qwen-Image 2.1 config. An unknown marker, tensor layout, missing key, or extra key is an error. + ``progress(done_bytes, total_bytes)`` reports completed source tensor batches. """ with _open_safetensors(path, framework="pt", device="cpu") as checkpoint: + reads = TensorProgress(checkpoint, progress) if progress is not None else None with torch.device("meta"): model = QwenImageDiT(**_config(checkpoint.metadata() or {}, QwenImageDiT, config)) keys = _keys(checkpoint, "model.diffusion_model.") @@ -120,6 +123,8 @@ def load_dit(path, device="cpu", *, config=None): raise ValueError(f"Invalid Comfy quantization JSON: {marker_key}") from exc if marker != CONVROT_METADATA or type(marker.get("convrot")) is not bool or type(marker.get("convrot_groupsize")) is not int: raise ValueError(f"Unsupported Comfy quantization format for {name}: {marker!r}") + if reads is not None: + reads.tensors(marker_key) targets, modules = _quant_targets(model, name) rows = sum(module.out_features for module in modules) width = modules[0].in_features @@ -144,6 +149,8 @@ def load_dit(path, device="cpu", *, config=None): raise ValueError(f"Invalid Comfy attention hint: {key}") from exc if hint != COMFY_ATTENTION_HINT: raise ValueError(f"Unsupported Comfy attention format for {key}: {hint!r}") + if reads is not None: + reads.tensors(keys[key]) consumed.add(key) ordinary = set(expected) - replaced missing, extra = ordinary - set(keys), set(keys) - consumed - ordinary @@ -174,23 +181,29 @@ def load_dit(path, device="cpu", *, config=None): _assign(model, target + ".weight_scale", scales[offset:offset + rows], device, torch.float32) offset += rows del weight, scales + if reads is not None: + reads.tensors(keys[name + ".weight"], keys[name + ".weight_scale"]) for key in sorted(ordinary): # A quantized Linear's original bias (if a future supported config has # one) lives under original.bias in the engine wrapper. parent, _, leaf = key.rpartition(".") target = key if parent not in specs or leaf != "bias" else parent + ".original.bias" _assign(model, target, checkpoint.get_tensor(keys[key]), device, torch.bfloat16) + if reads is not None: + reads.tensors(keys[key]) model.to(device=device) return model.eval().requires_grad_(False) -def load_vae(path, device="cpu", *, config=None): +def load_vae(path, device="cpu", *, config=None, progress=None): """Load the Comfy BF16 VAE, accepting only explicit one-to-one key mappings. Single-frame Comfy Conv3d weights may retain a size-one temporal axis. It is squeezed without numerical conversion; any other shape is rejected. + ``progress(done_bytes, total_bytes)`` counts source bytes before squeezing. """ with _open_safetensors(path, framework="pt", device="cpu") as checkpoint: + reads = TensorProgress(checkpoint, progress) if progress is not None else None with torch.device("meta"): model = QwenImageVAE(**_config(checkpoint.metadata() or {}, QwenImageVAE, config)) table = vae_key_map(model) @@ -220,4 +233,6 @@ def load_vae(path, device="cpu", *, config=None): tensor = tensor.squeeze(2) _assign(model, target, tensor, device, torch.bfloat16) del tensor + if reads is not None: + reads.tensors(key) return model.eval().requires_grad_(False) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index c3727d7e..9dae7ed7 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -389,6 +389,8 @@ def infer(self, session, value): if (stage in ('load', 'text', 'encode', 'denoise', 'decode') and type(fraction) in (int, float) and math.isfinite(fraction) and 0 <= fraction <= 1): with self.mutex: + if self.state.get('stage') == stage and type(self.state.get('fraction')) in (int, float): + fraction = max(fraction, self.state['fraction']) self.state.update(phase='cancelling' if self.stop.is_set() else 'progress', stage=stage, fraction=fraction) elif phase in ('result', 'error'): diff --git a/freevideo_engine/offload.py b/freevideo_engine/offload.py index d7d4e3ff..19e4353a 100644 --- a/freevideo_engine/offload.py +++ b/freevideo_engine/offload.py @@ -99,7 +99,7 @@ def live_pin_capacity(*, headroom_bytes=None, nonlocal_reserve_bytes=None, commi @torch.no_grad() def pin_layer_weights(layers, max_bytes=None, *, headroom_bytes=None, nonlocal_reserve_bytes=None, - commit_reserve_bytes=0): + commit_reserve_bytes=0, progress=None): """Pack immutable CPU parameters into persistent pinned planes, one per dtype. This optional mode trades pageable/mapped storage for locked host residency. @@ -174,6 +174,8 @@ def pin_layer_weights(layers, max_bytes=None, *, headroom_bytes=None, nonlocal_r offset += parameter.numel() parameter.data = view pinned += sum(p.numel() * p.element_size() for p in parameters) + if progress is not None: + progress(pinned, required) return pinned From 22f7bd62727b3119a814b1dca854921155410924 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:45:12 +0000 Subject: [PATCH 02/53] Editor shows the image model download state --- freevideo_engine/image_gen/image_pack.py | 27 ++++++++++++++++++++++++ freevideo_engine/image_gen/service.py | 6 ++++-- web/image_generation.js | 10 ++++++--- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/freevideo_engine/image_gen/image_pack.py b/freevideo_engine/image_gen/image_pack.py index f4d70881..79e64b32 100644 --- a/freevideo_engine/image_gen/image_pack.py +++ b/freevideo_engine/image_gen/image_pack.py @@ -8,6 +8,7 @@ """ from collections import deque from contextlib import ExitStack, contextmanager +import errno import json import logging from pathlib import Path, PurePosixPath @@ -263,6 +264,32 @@ def ready(root=None): return status(root)['ready'] +def downloading(root=None, engine_root=None) -> bool: + """Probe the existing pack lease without creating files or waiting.""" + try: + path = _path(base(root), 'pack.lock') + # install() holds this lease through transfers and generation pauses. + # A separate descriptor also detects a holder in this process. + with path.open('rb') as stream: + if sys.platform == 'win32': + import msvcrt + try: + msvcrt.locking(stream.fileno(), msvcrt.LK_NBRLCK, 1) + except OSError as error: + if error.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK): + raise + return True + else: + import fcntl + try: + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return True + return False + except Exception: + return False + + @contextmanager def _lease(root, engine_root=None, destructive=False): if root is None and engine_root is None: diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 9dae7ed7..ebdc69f0 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -89,9 +89,11 @@ def busy(self): return self._busy def info(self): - _, machine = self.installation() + engine_root, machine = self.installation() spec = image_pack.catalog() - return dict(ready=image_pack.ready(machine['model_root']), model=spec['repo'], + ready = image_pack.ready(machine['model_root']) + return dict(ready=ready, model=spec['repo'], + downloading=not ready and image_pack.downloading(machine['model_root'], engine_root), bytes=sum(row['bytes'] for row in spec['groups']['image']), busy=self.busy(), max_images=MAX_IMAGES) diff --git a/web/image_generation.js b/web/image_generation.js index 5144aedd..52f7aa20 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -5,7 +5,7 @@ import {orientMatrix, rasterKinds, scaleSelection} from './image_editor_engine.j export const IMAGE_GEN_PROTOCOL = Object.freeze({ root: '/freevideo/image-gen', header: 'X-FreeVideo-Image', upload: '/upload/image', - installed: 'ready', phase: 'phase', bytes: 'bytes', + installed: 'ready', phase: 'phase', bytes: 'bytes', downloading: 'downloading', token: 'token', job: 'job', prompt: 'prompt', width: 'width', height: 'height', images: 'images', seed: 'seed', stage: 'stage', fraction: 'fraction', file: 'file', error: 'error', name: 'name', subfolder: 'subfolder', states: {starting: 'queued', progress: 'running', complete: 'done', failed: 'error', @@ -100,12 +100,16 @@ export function imagePackMessage(info, t) { return t(`Download "${name}" in the launcher first, ${size}.`, `先在启动器里下载“${name}”,${size}。`); } export function imageGenError(code, info, t) { - if (code === 'pack_missing') return imagePackMessage(info, t); + if (code === 'pack_missing') return imagePackUnavailableMessage(info, t); if (code === 'busy_video') return t('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'); if (code === 'no_memory') return t('Not enough GPU memory. Make the picture smaller in "Image size…" and try again.', '显存不足。在“图像大小…”里缩小后再试。'); return t('The image could not be generated. Try again.', '没有生成成功,请再试一次。'); } +function imagePackUnavailableMessage(info, t) { + return info?.[IMAGE_GEN_PROTOCOL.downloading] + ? t('Downloading. Available when finished.', '正在下载,完成后可用。') : imagePackMessage(info, t); +} // The service accepts 256..2048 on each side; a larger canvas is generated at its // fitted size and the result is laid over the canvas like any other layer. @@ -368,7 +372,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection action.classList.toggle('fv-ie-primary', !state.running); action.disabled = !state.running && (disabled || !prompt.trim()); action.hidden = !!missing; const percent = Math.floor(state.fraction * 100) + '%'; - note.textContent = missing ? imagePackMessage(state.info, t) : state.error ? imageGenError(state.error, state.info, t) + note.textContent = missing ? imagePackUnavailableMessage(state.info, t) : state.error ? imageGenError(state.error, state.info, t) : state.running ? (state.stage === 'load' ? t('Loading the model', '加载模型') + ' ' : '') + percent : ''; note.hidden = !note.textContent; bar.dataset.running = String(state.running); bar.dataset.message = String(!!missing || !!state.error); From 586e063058b97b5137a85303ba99e8030d94366c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:45:19 +0000 Subject: [PATCH 03/53] Install, offline package and portable launcher fixes from the bug hunt --- freevideo_engine/comfy_launcher.py | 18 +++- freevideo_engine/comfy_updates.py | 2 +- freevideo_engine/launcher/Main.qml | 36 ++++--- freevideo_engine/launcher_copy.py | 17 +++ freevideo_engine/launcher_session.py | 148 +++++++++++++++++++++------ freevideo_engine/launcher_update.py | 11 +- freevideo_engine/modern_launcher.py | 11 +- freevideo_engine/offline_packages.py | 62 ++++++++--- freevideo_engine/package_cleanup.py | 87 +++++++++++----- web/launcher.js | 9 +- web/setup.js | 4 +- web/updates.js | 20 +++- 12 files changed, 323 insertions(+), 102 deletions(-) diff --git a/freevideo_engine/comfy_launcher.py b/freevideo_engine/comfy_launcher.py index 406ec782..d64c4224 100644 --- a/freevideo_engine/comfy_launcher.py +++ b/freevideo_engine/comfy_launcher.py @@ -20,6 +20,7 @@ FIREWALL_HINT = ('If Windows asks about the firewall the first time, choose Allow.', 'Windows 首次询问防火墙权限时,请选择“允许”。') +BROWSER_STARTING = ('Still starting. Your browser will open automatically when ready.', '还在启动,好了会自动打开') class Launcher: @@ -392,7 +393,11 @@ def show_launcher(self): self.refresh() def update_engine(self): - if self.controller.busy or not self.launch_selection: + if not self.launch_selection: + from .launcher_copy import ACTION_COPY + self.status.set(self.t(*ACTION_COPY['installation_missing'])) + return + if self.controller.busy: return self.remember_installation(self.launch_selection) self.wizard_step = 2 @@ -484,6 +489,13 @@ def reset_browser(self): def open_browser(self): if self.controller.state.get('status') != 'open': + # The controller can change state before the next GUI refresh. + # A click on the previous enabled button still gets a response. + if self.controller.busy and self.controller.state.get('action') in ('launch', 'connect', 'install'): + self.browser_attempted = False + self.status.set(self.t(*BROWSER_STARTING)) + else: + self.refresh() return from .windows_ux import open_browser self.browser_attempted = True @@ -539,7 +551,7 @@ def refresh(self): ('Start ComfyUI and open the FreeVideo workflow.', '启动 ComfyUI 并打开 FreeVideo 工作流。')] if self.wizard_step == 3: descriptions[3] = { - 'running': ('Starting ComfyUI…', '正在启动 ComfyUI…'), + 'running': BROWSER_STARTING, 'open': ('ComfyUI is ready. Open FreeVideo in your browser.', 'ComfyUI 已就绪,可在浏览器中打开 FreeVideo。'), 'failed': ('Could not start. Details below.', '启动未完成,原因见下方。'), 'restart-required': ('Restart your running ComfyUI, then connect again.', '请重启正在运行的 ComfyUI,然后重新连接。'), @@ -602,7 +614,7 @@ def refresh(self): self.browser_controls.pack(fill='x', pady=(16, 0)) else: self.browser_controls.pack_forget() - self.browser_button.state(['disabled'] if busy else ['!disabled']) + self.browser_button.state(['disabled'] if busy or state != 'open' else ['!disabled']) self.shortcut_button.state(['!disabled'] if not busy and self.controller.selection and self.controller.selection.get('ready') else ['disabled']) shortcut = row.get('shortcut', {}) diff --git a/freevideo_engine/comfy_updates.py b/freevideo_engine/comfy_updates.py index cfcad47e..c3d477b0 100644 --- a/freevideo_engine/comfy_updates.py +++ b/freevideo_engine/comfy_updates.py @@ -37,7 +37,7 @@ def launcher_view(status): """The parts of a launcher status a page may show; None without a launcher.""" if not isinstance(status, dict): return None - view = {k: status.get(k) for k in ('version', 'phase', 'status', 'progress', 'candidate', 'engine', 'error', 'manual', 'channel', 'track')} + view = {k: status.get(k) for k in ('version', 'phase', 'model', 'status', 'progress', 'candidate', 'engine', 'error', 'manual', 'channel', 'track')} view['phase'] = str(view['phase'] or '') return view diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index f8b5c641..d5681062 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -248,7 +248,7 @@ ApplicationWindow { Accessible.role: Accessible.Graphic; Accessible.name: "FreeVideo" } FNav { objectName: "navLauncher"; glyph: "play"; text: t("Launch", "启动"); Layout.fillWidth: true; selected: s.page === "launcher"; enabled: s.selected && !s.busy; onClicked: backend.action("launcher", false) } - FNav { objectName: "navSetup"; glyph: "download"; text: t("Installation", "安装"); Layout.fillWidth: true; selected: s.page !== "launcher"; enabled: !s.busy && !s.portable; onClicked: backend.action("setup", false) } + FNav { objectName: "navSetup"; glyph: "download"; text: t("Installation", "安装"); Layout.fillWidth: true; selected: s.page !== "launcher"; enabled: s.can_manage && !s.portable; onClicked: backend.action("setup", false) } Item { Layout.fillHeight: true } Rectangle { Layout.fillWidth: true; Layout.bottomMargin: 8; implicitHeight: busyBody.implicitHeight + 28 @@ -374,8 +374,11 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true; spacing: 8; Layout.bottomMargin: shortWindow ? 0 : 10 FText { objectName: "pageHeading"; horizontalAlignment: Text.AlignHCenter; text: s.page === "launcher" ? t("Your workspace", "你的工作空间") : s.page === "comfy" ? t("Set up FreeVideo", "安装 FreeVideo") : s.page === "models" ? t("Set up your models", "准备模型") : s.status === "review" ? t("Ready to install", "确认安装") : s.busy ? t("Setting things up", "正在准备 FreeVideo") : t("Continue your setup", "继续安装"); font.pixelSize: win.compact ? 26 : theme.hero; font.weight: Font.DemiBold; font.letterSpacing: -0.6; Layout.fillWidth: true } + FText { objectName: "actionNotice"; visible: !!s.action_notice; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true; horizontalAlignment: Text.AlignHCenter } } + FText { objectName: "operationBusyReason"; visible: !s.can_manage && !s.action_notice; text: s.busy_reason; color: theme.muted; Layout.fillWidth: true; horizontalAlignment: Text.AlignHCenter } + Rectangle { id: failureCard objectName: "failureCard"; visible: !!s.error; Layout.fillWidth: true @@ -471,8 +474,8 @@ ApplicationWindow { FText { text: s.form.new_comfy ? t("Install location", "安装位置") : t("ComfyUI folder", "ComfyUI 目录"); font.weight: Font.DemiBold } RowLayout { Layout.fillWidth: true; spacing: 8 - FField { objectName: "installationPath"; Layout.fillWidth: true; value: s.form.new_comfy ? s.form.destination : s.form.comfy; placeholderText: t("Choose a folder", "选择文件夹"); enabled: !s.busy; onEditingFinished: backend.edit(s.form.new_comfy ? "destination" : "comfy", text) } - FButton { text: t("Browse…", "浏览…"); implicitHeight: theme.height + 4; onClicked: backend.browse(s.form.new_comfy ? "destination" : "comfy") } + FField { objectName: "installationPath"; Layout.fillWidth: true; value: s.form.new_comfy ? s.form.destination : s.form.comfy; placeholderText: t("Choose a folder", "选择文件夹"); enabled: s.can_manage; onEditingFinished: backend.edit(s.form.new_comfy ? "destination" : "comfy", text) } + FButton { objectName: "installationBrowse"; text: t("Browse…", "浏览…"); enabled: s.can_manage; implicitHeight: theme.height + 4; onClicked: backend.browse(s.form.new_comfy ? "destination" : "comfy") } } FText { visible: !s.form.new_comfy; text: t("Uses your existing Python when available. A separate environment loads only FreeVideo.", "优先使用已有 Python;独立环境仅加载 FreeVideo。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } RowLayout { @@ -483,7 +486,7 @@ ApplicationWindow { Layout.alignment: Qt.AlignBaseline; Layout.rightMargin: s.zh ? -(8 + Math.round(font.pixelSize / 2) - 2) : -4 } FText { objectName: "installDiskText"; text: diskLine(); color: disk && (disk.problem || disk.short) ? theme.danger : theme.muted; font.pixelSize: theme.micro + 1; Layout.fillWidth: true; Layout.alignment: Qt.AlignBaseline } // The label's right edge lines up with Browse; the hover fill reaches into the card's padding. - FButton { objectName: "changeDisk"; visible: s.form.new_comfy && diskChoices.length > 0; enabled: !s.busy; flat: true; implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1 + FButton { objectName: "changeDisk"; visible: s.form.new_comfy && diskChoices.length > 0; enabled: s.can_manage; flat: true; implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1 leftPadding: 10; rightPadding: 10; Layout.rightMargin: -10; Layout.alignment: Qt.AlignBaseline text: t("Choose another disk", "换一块硬盘"); onClicked: diskDialogOpen = true } } @@ -535,7 +538,7 @@ ApplicationWindow { FText { text: t("Reuse models · optional", "复用已有模型(可选)"); font.weight: Font.DemiBold; Layout.fillWidth: true } FText { text: t("Add folders to find matching models in their subfolders.", "添加总目录,自动匹配子文件夹中的模型。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } - FButton { objectName: "addModelFolder"; text: t("Add folder", "添加目录"); enabled: !s.busy; onClicked: backend.browse("model_dirs") } + FButton { objectName: "addModelFolder"; text: t("Add folder", "添加目录"); enabled: s.can_manage; onClicked: backend.browse("model_dirs") } } Repeater { model: s.form.model_dirs @@ -805,7 +808,7 @@ ApplicationWindow { objectName: "modelUpgradeButton"; primary: true visible: upgrade.status === "available" || upgrade.status === "releasable" || (upgrade.status === "failed" && (upgrade.step === "download" || upgrade.step === "release" || (upgrade.step === "switch" && upgrade.kept))) - enabled: !s.busy && !upgrade.busy + enabled: s.can_manage && !upgrade.busy text: upgrade.status === "failed" ? t("Try again", "重试") : upgrade.status === "releasable" ? t("Remove…", "删除…") : t("Download & upgrade", "下载并升级") onClicked: upgrade.status === "releasable" ? releaseConfirm = true : backend.upgradeModel() } @@ -923,8 +926,8 @@ ApplicationWindow { FButton { objectName: "launchInstalledButton"; visible: updateFirst && s.retry_kind !== "launch"; flat: true; text: t("Launch current version", "启动当前版本"); onClicked: backend.action("primary", accepted) } FButton { objectName: "primaryButton"; primary: !s.repair_offered && !(s.retry_kind === "launch" && engineUpdate.visible) && !manualFailure; implicitWidth: Math.max(160, contentItem.implicitWidth+40); implicitHeight: theme.heightLg - text: s.busy ? t("Working…", "正在处理…") : primaryText() - enabled: !s.busy && !(s.page === "launcher" && s.needs_consent && !accepted) + text: s.setup_pending ? s.setup_wait_text : s.busy ? t("Working…", "正在处理…") : primaryText() + enabled: !s.busy && !s.setup_pending && !(s.page === "launcher" && s.needs_consent && !accepted) && !(s.page === "progress" && s.status === "review" && !s.retry_kind && (!accepted || !!s.error)) onClicked: { if (s.retry_kind) { backend.action("retry", accepted); return } if (needsRuntime) { backend.browseRuntimePackage(); return } if (needsPackages) { backend.browsePackages(); return } if (updateFirst) { backend.update(""); return } backend.action(s.page === "launcher" && s.status === "open" ? "browser" : "primary", accepted); if (s.busy && s.page === "launcher") terminalOpen = true } } @@ -1009,6 +1012,8 @@ ApplicationWindow { ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ColumnLayout { width: settingsScroll.availableWidth; spacing: 18 + FText { objectName: "settingsActionNotice"; visible: !!s.action_notice && s.action_notice !== s.busy_reason; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true } + FText { objectName: "settingsBusyReason"; visible: (!s.can_manage || !s.can_cleanup) && (!s.action_notice || s.action_notice === s.busy_reason); text: s.busy_reason; color: theme.muted; Layout.fillWidth: true } ColumnLayout { Layout.fillWidth: true; visible: settingsTab === "downloads"; spacing: 20 FGroup { @@ -1083,7 +1088,7 @@ ApplicationWindow { FText { text: t("Updates", "更新"); Layout.fillWidth: true } FText { text: t("Settings are kept in Documents across updates.", "设置保存在文档目录中,更新后保留。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } } - FButton { objectName: "checkUpdatesButton"; text: t("Check now", "检查更新"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: { manualUpdate = true; backend.checkUpdates("") } } + FButton { objectName: "checkUpdatesButton"; text: s.update.check_requested ? s.update.check_text : t("Check now", "检查更新"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: { manualUpdate = true; backend.checkUpdates("") } } } FButton { text: t("Version & release notes", "版本与更新说明") + " · " + releaseVersion(currentRelease); flat: true; Layout.fillWidth: true; onClicked: releaseNotesOpen = true } } @@ -1176,7 +1181,7 @@ ApplicationWindow { objectName: "modelStorageButton"; visible: upgrade.status === "available" || upgrade.status === "releasable" text: upgrade.status === "releasable" ? t("Remove…", "删除…") : t("Upgrade", "升级") implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1 - enabled: !s.busy && !upgrade.busy + enabled: s.can_manage && !upgrade.busy onClicked: upgrade.status === "releasable" ? releaseConfirm = true : backend.upgradeModel() } } @@ -1198,7 +1203,7 @@ ApplicationWindow { FButton { text: t("Qt notices & licenses ↗", "Qt 组件与许可证 ↗"); flat: true; implicitHeight: theme.heightSm; leftPadding: 4; font.pixelSize: theme.micro; onClicked: backend.link("https://doc.qt.io/qt-6/licenses-used-in-qt.html") } } ColumnLayout { - Layout.fillWidth: true; visible: settingsTab === "advanced"; spacing: 20; enabled: !s.busy + Layout.fillWidth: true; visible: settingsTab === "advanced"; spacing: 20; enabled: s.can_manage FGroup { Layout.fillWidth: true; title: t("ComfyUI address", "ComfyUI 地址") FField { objectName: "listenAddress"; Layout.fillWidth: true; value: s.form.url; enabled: !s.form.lan_access; onEditingFinished: backend.edit("url", text) } @@ -1208,7 +1213,7 @@ ApplicationWindow { FGroup { Layout.fillWidth: true; title: t("Folders · optional", "目录 · 可选"); enabled: !s.portable FText { text: t("Engine folder", "引擎目录"); font.pixelSize: theme.micro; color: theme.muted } - RowLayout { Layout.fillWidth: true; spacing: 8; FField { Layout.fillWidth: true; value: s.form.engine; placeholderText: t("Automatic", "自动选择"); onEditingFinished: backend.edit("engine", text) } FButton { text: t("Browse…", "浏览…"); implicitHeight: theme.height + 4; onClicked: backend.browse("engine") } } + RowLayout { Layout.fillWidth: true; spacing: 8; FField { Layout.fillWidth: true; value: s.form.engine; placeholderText: t("Automatic", "自动选择"); onEditingFinished: backend.edit("engine", text) } FButton { objectName: "engineBrowse"; text: t("Browse…", "浏览…"); enabled: s.can_manage; implicitHeight: theme.height + 4; onClicked: backend.browse("engine") } } FText { text: t("ComfyUI Python", "ComfyUI Python"); font.pixelSize: theme.micro; color: theme.muted; Layout.topMargin: 4 } FField { Layout.fillWidth: true; value: s.form.python; placeholderText: t("Path to python.exe", "python.exe 的路径"); onEditingFinished: backend.edit("python", text) } } @@ -1301,7 +1306,7 @@ ApplicationWindow { } RowLayout { Layout.fillWidth: true; Layout.topMargin: 4; spacing: 8 - FButton { text: t("Choose a folder…", "选择文件夹…"); flat: true; onClicked: { diskDialogOpen = false; backend.browse("destination") } } + FButton { text: t("Choose a folder…", "选择文件夹…"); enabled: s.can_manage; flat: true; onClicked: { diskDialogOpen = false; backend.browse("destination") } } Item { Layout.fillWidth: true } FButton { text: t("Cancel", "取消"); onClicked: diskDialogOpen = false } } @@ -1402,10 +1407,12 @@ ApplicationWindow { FText { objectName: "downloadListNotice"; visible: text.length > 0; text: s.download_list.notice; color: theme.success; Layout.fillWidth: true; font.pixelSize: theme.micro + 1 } FButton { objectName: "openDownloadFolder"; text: downloads.copy.open_folder; flat: true; implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: backend.openDownloadFolder() } } + FText { objectName: "downloadActionNotice"; visible: !!s.action_notice && s.action_notice !== s.busy_reason; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true } FText { objectName: "downloadListError"; visible: text.length > 0; text: s.download_list.error; color: theme.danger; Layout.fillWidth: true; wrapMode: Text.WordWrap; font.pixelSize: theme.micro + 1 } FText { objectName: "downloadListRawError"; visible: s.error.length > 0 && !s.download_list.inspection_error; text: s.error; color: theme.danger; Layout.fillWidth: true; maximumLineCount: 2; elide: Text.ElideRight; font.pixelSize: theme.micro } } } + FText { objectName: "downloadBusyReason"; visible: !s.can_manage && (!s.action_notice || s.action_notice === s.busy_reason); text: s.busy_reason; color: theme.muted; Layout.fillWidth: true } RowLayout { objectName: "downloadListActions"; Layout.fillWidth: true; Layout.minimumWidth: 0; spacing: 8 // The actions share what the Close button leaves, so a long label wraps instead of leaving the dialog. @@ -1414,7 +1421,7 @@ ApplicationWindow { FButton { id: closeDownloadList; text: downloads.copy.close; flat: true; onClicked: modelInfoOpen = false } Item { Layout.fillWidth: true } FButton { id: checkDownloadList; objectName: "checkDownloadList"; Layout.maximumWidth: parent.actionWidth; visible: !downloads.available || s.download_list.needs_check; text: downloads.copy.check; enabled: !s.busy; onClicked: backend.prepareDownloadList() } - FButton { objectName: "useDownloadedFolder"; Layout.maximumWidth: parent.actionWidth; text: downloads.copy.use_folder; enabled: !s.busy; onClicked: backend.useDownloadedFolder() } + FButton { objectName: "useDownloadedFolder"; Layout.maximumWidth: parent.actionWidth; text: downloads.copy.use_folder; enabled: s.can_manage; onClicked: backend.useDownloadedFolder() } FButton { objectName: "exportDownloadList"; Layout.maximumWidth: parent.actionWidth; text: downloads.copy.export; primary: true; enabled: downloads.available && !s.busy; onClicked: backend.exportDownloadList() } } } @@ -1496,6 +1503,7 @@ ApplicationWindow { ColumnLayout { id: updateContents; width: updateScroll.availableWidth spacing: 14 + FText { objectName: "updateActionNotice"; visible: !!s.action_notice; text: s.action_notice || ""; color: theme.muted; Layout.fillWidth: true } FText { text: s.update.candidate ? t("Update available", "有可用更新") : s.update.engine ? t("Engine update", "引擎更新") : t("Updates", "更新"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold } FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "还有视频正在生成,完成后会自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启即可完成更新,模型和设置都会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Could not check for updates.", "暂时无法检查更新。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: true } FMeter { Layout.fillWidth: true; visible: s.update.status === "downloading"; active: true; fraction: s.update.progress && s.update.progress.total ? s.update.progress.done/s.update.progress.total : -1 } diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index 3cccaa0a..9b8f301b 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -6,6 +6,23 @@ from .model_status import NAMES +ACTION_COPY = { + 'download_missing': ('The download folder no longer exists. Export the list again.', + '下载目录已不存在,请重新导出清单'), + 'installation_missing': ('There is no installation information yet. Complete installation first.', + '还没有安装信息,请先完成安装'), + 'checking_update': ('Checking for updates. Please wait.', '正在检查更新,请稍候'), + 'busy': ('The background task is busy. You can do this when it finishes.', + '后台正在忙,完成后可以操作'), + 'import_queued': ('Saved. Importing will start when the current task finishes.', + '已记下,处理完当前任务后自动导入'), + 'setup_queued': ('Models are being prepared in the background. Setup will continue when they are ready.', + '后台正在准备模型,好了会接着继续'), + 'update_queued': ('Another task is running. Updating will start when it finishes.', + '正在做别的事,完成后会自动更新'), +} + + SOURCES = { 'auto': ('Automatic', '自动选择'), 'official': ('Hugging Face', 'Hugging Face'), 'hf-mirror': ('HF Mirror', 'HF 镜像'), 'modelscope': ('ModelScope', '魔搭'), diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 98a56697..c6201eb6 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -15,7 +15,7 @@ from .desktop_runtime import launcher_root, materialize_source from .download_settings import Probe, read as download_preferences, speed_text from .launcher_settings import Store, default_language -from .launcher_copy import size_text +from .launcher_copy import ACTION_COPY, size_text from .launcher_terminal import Tail from .model_status import FAMILIES, NAMES from .model_guidance import (package_instructions, video_instructions, runtime_packages_supported, download_view, @@ -66,6 +66,12 @@ class Session: # The source whose earlier copies were removed once ComfyUI ran it. old_versions_retired = None archives_busy = False + action_notice = '' + pending_update = None + pending_check = None + upgrade = image_installer = None + model_switch = None + language = 'en' def __init__(self, source=None, *, controller=None, store=None, updater=None, smoke=False): from .offline_packages import Importer @@ -106,6 +112,11 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm self.token = '' self.error = '' self.notice = '' + self.action_notice = '' + self.pending_update = None + self.pending_action = None + self.pending_imports = [] + self.pending_check = None self.report = dict(status='idle', path='', error='') self.compatibility = dict(available=False, level=0, automatic=False) self.probe = Probe() @@ -176,6 +187,19 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm def t(self, en, zh): return zh if self.language.startswith('zh') else en + def task_busy(self): + """Work that must finish before another installation operation starts.""" + return bool(self.controller.busy or self.importer.busy or self.cleaner.busy + or self.archives_busy or self.closing or self.upgrade and self.upgrade.active + or self.model_switch or self.engine_updating + or self.image_installer and self.image_installer.busy) + + def can_manage(self): + return not (self.task_busy() or self.update_intent) + + def explain_busy(self): + self.action_notice = 'busy' + def engine_root(self): if self.form['engine'].strip(): return Path(plain(resolved(Path(self.form['engine']).expanduser()))) @@ -213,8 +237,8 @@ def remember(self, selection, persist=True): self.persist() def edit(self, key, value): - if self.image_installer and self.image_installer.busy and key in ( - 'engine', 'comfy', 'destination', 'new_comfy', 'offline_runtime'): + if key in ('engine', 'comfy', 'destination', 'new_comfy', 'offline_runtime') and not self.can_manage(): + self.explain_busy() return if key == 'language': self.language = str(value); self.persist(); return @@ -223,7 +247,10 @@ def edit(self, key, value): raise ValueError(self.t('Pause installation before changing the token.', '请先暂停安装,再修改 Token。')) from .hf_auth import validate self.token = validate(str(value)); return - if key not in self.form or self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy: + if key not in self.form: + return + if self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy: + self.explain_busy() return if key in ('separate', 'repair', 'new_comfy', 'sampling_caches', 'lan_access', 'image_model'): value = bool(value) @@ -275,7 +302,10 @@ def edit(self, key, value): def use_disk(self, folder): """Move a new installation to another disk, then show the location page to confirm it.""" - if not self.form['new_comfy'] or self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy: + if not self.can_manage(): + self.explain_busy() + return + if not self.form['new_comfy']: return self.edit('destination', str(folder)) self.page = 'comfy' @@ -327,7 +357,8 @@ def export_downloads(self, folder): self.download_error = self.t(*DOWNLOAD_COPY['error_' + error.key]).format(reason=reason) def use_downloaded_folder(self, folder): - if self.controller.busy or self.importer.busy or self.cleaner.busy: + if not self.can_manage(): + self.explain_busy() return self.add_folder(folder) # Even a folder already in the list may have received new downloads. @@ -367,10 +398,12 @@ def clear_runtime(self): def import_packages(self, paths): - if self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy: - return if not paths: return + if self.task_busy() or self.update_intent or self.pending_update: + self.pending_imports = list(dict.fromkeys(self.pending_imports + list(paths))) + self.action_notice = 'import_queued' + return self.repair_request = None destination = (Path(plain(resolved(Path(self.form['destination']).expanduser()))) if self.form['new_comfy'] else self.engine_root().parent) @@ -423,7 +456,17 @@ def activate_offline(self, root): self.page = 'launcher' self.persist() - def action(self, name, accepted=False): + def action(self, name, accepted=False, *, retry=None): + if name == 'stop': + self.pending_action = None + if self.action_notice == 'setup_queued': + self.action_notice = '' + setup_action = (name == 'primary' and self.page != 'launcher' + or name == 'retry' and self.retry_kind() != 'launch') + if setup_action and self.task_busy(): + self.pending_action = (name, accepted, self.page, self.retry_kind() if name == 'retry' else None) + self.action_notice = 'setup_queued' + return if self.cleaner.busy or self.archives_busy: return if self.image_installer and self.image_installer.busy and ( @@ -466,7 +509,7 @@ def action(self, name, accepted=False): self.started = time.monotonic() return if name == 'retry': - kind = self.retry_kind() + kind = retry or self.retry_kind() if kind == 'import': _, paths, destination = self.import_retry self.importer.start(paths, destination) @@ -604,10 +647,8 @@ def retry_kind(self): return '' def cleanup_downloads(self, remove=False): - if self.image_installer and self.image_installer.busy: - return - if (self.controller.busy or self.importer.busy or self.archives_busy or self.closing or self.upgrade.active or self.model_switch - or self.update_intent or self.engine_updating or self.updater and self.updater.busy): + if not self.can_manage() or self.updater and self.updater.busy: + self.explain_busy() return self.cleaner.start(self.engine_root(), remove=remove) @@ -721,7 +762,8 @@ def upgrade_model(self): the previous model. An old model left after the switch is released here too, when the user asks. """ - if self.image_installer and self.image_installer.busy: + if not self.can_manage(): + self.explain_busy() return state = self.upgrade.state if (state.get('status') not in ('available', 'failed', 'releasable') or self.upgrade.busy or self.model_switch @@ -990,6 +1032,10 @@ def update_key(self): return '' def dismiss_update(self): + self.pending_update = None + self.bridge_pending = False + if self.action_notice == 'update_queued': + self.action_notice = '' key = self.update_key() if key: self.update_snoozed = dict(self.update_snoozed, **{key: time.monotonic() + SNOOZE_SECONDS}) @@ -1005,20 +1051,31 @@ def _unsnooze(self): self.update_snoozed = {k: v for k, v in self.update_snoozed.items() if k != key} def check_update(self, token=''): - if self.updater and not self.updater.busy: - if token.strip(): - self.updater.token = token.strip() - self.update_checked = time.monotonic() - self.updater.run('check') + if not self.updater: + return + self.action_notice = 'checking_update' + if self.updater.busy: + self.pending_check = token + return + self.pending_check = None + if token.strip(): + self.updater.token = token.strip() + self.update_checked = time.monotonic() + self.updater.run('check') def update(self, token='', source='launcher'): """One explicit update: the newest launcher if one is known, otherwise the engine bundled with this launcher. Downloading, waiting for running ComfyUI jobs and restarting continue from tick().""" - if self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy or self.closing: - if source == 'browser': - self.bridge_pending = True + if self.engine_updating or self.update_restarting: return + if self.task_busy() or self.updater and self.updater.busy: + self.pending_update = (token, source) + self.action_notice = 'update_queued' + return + self.pending_update = None + if self.action_notice == 'update_queued': + self.action_notice = '' self.repair_request = None if self.updater and token.strip(): self.updater.token = token.strip() @@ -1095,7 +1152,7 @@ def _installation_ready(self): return False def _restart_when_idle(self): - if self.archives_busy: + if self.task_busy(): return row = self.updater.state if row.get('status') != 'ready' or self.update_restarting: @@ -1122,7 +1179,7 @@ def _restart_when_idle(self): self.closing = True def _update_engine_when_idle(self): - if self.controller.busy or self.importer.busy or self.archives_busy or self.upgrade.active or self.model_switch: + if self.task_busy(): return busy = self._queue_busy() self.update_waiting = bool(busy) @@ -1149,7 +1206,13 @@ def _tick_updates(self): updater = self.updater if updater: updater.poll(allow_download=not (self.controller.busy or self.importer.busy or self.cleaner.busy)) - if (updater and self.update_intent is None and not updater.busy and now - self.update_checked >= CHECK_SECONDS + if (updater and self.pending_check is not None and not updater.busy and not self.closing + and not self.update_intent and not self.pending_update): + self.check_update(self.pending_check) + if (self.action_notice == 'checking_update' and self.pending_check is None + and updater and not updater.busy and updater.state.get('status') != 'checking'): + self.action_notice = '' + if (updater and self.update_intent is None and not self.pending_update and not updater.busy and now - self.update_checked >= CHECK_SECONDS and not updater.retry and updater.state.get('status') in ('idle', 'current', 'available', 'error', 'cancelled')): self.update_checked = now @@ -1162,11 +1225,14 @@ def _tick_updates(self): request = take_request(self.bridge) if request and request['action'] == 'cancel': self.bridge_pending = False - if self.update_intent: + if self.update_intent or self.pending_update: self.dismiss_update() elif request or (self.bridge_pending and not self.controller.busy): self.bridge_pending = False self.update(source='browser') + if self.pending_update and not self.task_busy() and not (updater and updater.busy): + token, source = self.pending_update + self.update(token, source) row = updater.state if updater else {} if self.update_intent == 'check' and updater and not updater.busy: self.update_intent = None @@ -1299,7 +1365,9 @@ def update_view(self): installed=self.installed_version(), phase=self.update_phase(), key=key, channel=self.updater.current.get('channel') if self.updater else None, track=self.updater.current.get('track', 'stable') if self.updater else 'stable', - manual=bool(self.updater and self.updater.current.get('packaging') in ('onedir', 'app'))) + manual=bool(self.updater and self.updater.current.get('packaging') in ('onedir', 'app')), + check_requested=self.pending_check is not None or row.get('status') == 'checking', + check_text=self.t(*ACTION_COPY['checking_update'])) # Versions between the one in use and the update, so skipped notes are not lost. from .release_notes import since current = view['current_release'] or {} @@ -1538,6 +1606,23 @@ def tick(self): self.tail.select(selected) self.tail.read(final=not self.controller.terminal_running(selected)) self._tick_updates() + if self.action_notice == 'busy' and self.can_manage() and not (self.updater and self.updater.busy): + self.action_notice = '' + if self.pending_imports and not self.task_busy() and not self.update_intent and not self.pending_update: + paths, self.pending_imports = self.pending_imports, [] + if self.action_notice == 'import_queued': + self.action_notice = '' + self.import_packages(paths) + if self.pending_action and not self.task_busy() and not self.update_intent and not self.pending_update: + name, accepted, page, retry = self.pending_action + self.pending_action = None + if self.action_notice == 'setup_queued': + self.action_notice = '' + self.page = page + if retry: + Session.action(self, name, accepted, retry=retry) + else: + self.action(name, accepted) def _tick_page_check(self, row): """Show a page that failed to load FreeVideo, or never reported, in the failure card.""" @@ -1935,10 +2020,11 @@ def snapshot(self): return plain_data(dict(download_list=download_state, sampling_cache_bytes=total_bytes(), version=__version__, image_pack=image_pack, disk=disk, decimal_sizes=sys.platform == 'darwin', zh=self.language.startswith('zh'), form=dict(self.form), page=self.page, status=row.get('status', 'idle'), busy=busy, + action_notice=self.t(*ACTION_COPY[self.action_notice]) if self.action_notice else '', + setup_pending=bool(self.pending_action), setup_wait_text=self.t(*ACTION_COPY['setup_queued']), + can_manage=self.can_manage(), busy_reason=self.t(*ACTION_COPY['busy']), cleanup=cleanup, - can_cleanup=not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy or self.closing - or self.update_intent or self.engine_updating or self.updater and self.updater.busy - or self.upgrade.active or self.model_switch or image_pack['busy']), + can_cleanup=self.can_manage() and not (self.updater and self.updater.busy), model_upgrade=dict(model_upgrade, switching=bool(self.model_switch)), offline=dict(progress_view(self.importer.state, zh), runtime=bool(self.form['offline_runtime']), runtime_supported=runtime_packages_supported(), diff --git a/freevideo_engine/launcher_update.py b/freevideo_engine/launcher_update.py index 290d5ce4..8598c7c8 100644 --- a/freevideo_engine/launcher_update.py +++ b/freevideo_engine/launcher_update.py @@ -3,6 +3,7 @@ Executables live in a versioned user cache. The original EXE stays intact and forwards to a newer, explicitly approved download on subsequent starts. """ +import errno import hashlib from email.utils import parsedate_to_datetime import json @@ -82,6 +83,8 @@ def retry_after(error): def failure_kind(error): if isinstance(error, UpdateUnavailable): return error.kind + if isinstance(error, OSError) and error.errno == errno.ENOSPC: + return 'disk' if isinstance(error, urllib.error.HTTPError): headers = {str(k).lower(): v for k, v in (error.headers or {}).items()} if error.code == 429 or (error.code == 403 and @@ -625,9 +628,11 @@ def progress(value): self.failures = 0 except Exception as error: self.failures += 1 - row = dict(status='cancelled' if self.cancelled.is_set() else 'error', - error_code=failure_kind(error), candidate=selected or self.available) - if (not self.cancelled.is_set() and row['error_code'] not in ('invalid', 'disk') + stopped = self.cancelled.is_set() + row = dict(status='cancelled' if stopped else 'error', candidate=selected or self.available) + if not stopped: + row['error_code'] = failure_kind(error) + if (not stopped and row['error_code'] not in ('invalid', 'disk') and (operation == 'check' or self.failures <= MAX_DOWNLOAD_RETRIES)): delay = max(min(MAX_RETRY_SECONDS, RETRY_SECONDS * 2**min(self.failures - 1, 4)), retry_after(error)) diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 4fa9b5fd..8fad2d28 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -175,7 +175,8 @@ def dismissModelUpgrade(self): @Slot(str) def browse(self, key): - if session.controller.busy: + if not session.can_manage(): + self.invoke(session.explain_busy) return path = QFileDialog.getExistingDirectory(None, session.t('Choose folder', '选择文件夹'), str(session.form.get(key) or ''), QFileDialog.ShowDirsOnly) @@ -196,7 +197,8 @@ def exportDownloadList(self): @Slot() def useDownloadedFolder(self): - if session.controller.busy or session.importer.busy: + if not session.can_manage(): + self.invoke(session.explain_busy) return path = QFileDialog.getExistingDirectory(None, session.downloads_view()['copy']['use_folder'], '', QFileDialog.ShowDirsOnly) if path: @@ -278,7 +280,12 @@ def openDownloadFolder(self): from PySide6.QtGui import QDesktopServices folder = session.download_export if folder and Path(folder).is_dir(): + if session.action_notice == 'download_missing': + session.action_notice = '' self.invoke(QDesktopServices.openUrl, QUrl.fromLocalFile(str(Path(folder).resolve()))) + else: + session.action_notice = 'download_missing' + self.refresh() @Slot(str) def selectSource(self, value): diff --git a/freevideo_engine/offline_packages.py b/freevideo_engine/offline_packages.py index 4f6c5267..62810c83 100644 --- a/freevideo_engine/offline_packages.py +++ b/freevideo_engine/offline_packages.py @@ -5,6 +5,7 @@ from pathlib import Path import shutil import stat +import tempfile import threading import time import zipfile @@ -202,8 +203,8 @@ def inspect_archive(path): return dict(value, marker=marker, raw=raw, id=hashlib.sha256(raw).hexdigest()) -# Size and modification time of each file an import has hashed. A later check -# of the same unchanged file trusts them instead of hashing tens of GiB again. +# Size, modification time and checked digest of each imported file. A later +# check of the same unchanged content avoids hashing tens of GiB again. VERIFIED = 'import-verified.json' @@ -221,10 +222,10 @@ def stamp(path): def unchanged(path, row, known, current=None, progress=None): - """Still the file its import checked: the size matches, and the recorded time too. + """Still the content its import checked: size, time and expected digest match. - Only a file whose record says it was rewritten since is hashed. `current` - is its [size, mtime] when a directory walk already read it. + Missing or earlier size/time-only records require hashing. `current` is + its [size, mtime] when a directory walk already read it. """ try: current = current or stamp(path) @@ -232,7 +233,7 @@ def unchanged(path, row, known, current=None, progress=None): return False if current[0] != row['bytes']: return False - if known.get(row['path']) in (None, current): + if known.get(row['path']) == [*current, row['sha256']]: return True from .network import hash_file return hash_file(path, **({'progress': progress} if progress else {})) == row['sha256'] @@ -256,11 +257,18 @@ def copy_progress(source, target, progress): def import_archive(path, destination, progress=lambda **kw: None, cancelled=lambda: False): - """Extract once, checked by the ZIP's own CRC-32 and each file's size. + """Extract once, checking CRC-32, size and inventory SHA-256 while streaming. A file is written under a temporary name and renamed only when complete, so a file found under its own name was checked when it was written. """ + from .locking import runtime_lock + # This lease also excludes package cleanup and other launcher instances. + with runtime_lock(resolved(destination) / '.freevideo-packages/import.lock', inherit=False): + return _import_archive(path, destination, progress, cancelled) + + +def _import_archive(path, destination, progress, cancelled): path = resolved(path) progress(done=0, total=None, detail=path.name) archive = path.stat() @@ -285,9 +293,9 @@ def import_archive(path, destination, progress=lambda **kw: None, cancelled=lamb if current[0] != row['bytes'] or not unchanged(root / row['path'], row, known, current, progress=file_progress(progress, 'verify', row['bytes'])): raise ValueError('已有导入文件已改变,未覆盖:' + plain(root / row['path'])) - verified[row['path']] = current + verified[row['path']] = [*current, row['sha256']] marker = root / value['marker'] - if not missing and marker.is_file() and marker.read_bytes() == value['raw']: + if len(verified) == len(value['files']) and marker.is_file() and marker.read_bytes() == value['raw']: # Imported before and complete: nothing to read again. save(root / VERIFIED, verified) progress(done=total, total=total, detail=path.name, activity='') @@ -306,6 +314,7 @@ def import_archive(path, destination, progress=lambda **kw: None, cancelled=lamb target = root / row['path'] target.parent.mkdir(parents=True, exist_ok=True) partial = target.with_name(target.name + '.importing') + digest = hashlib.sha256() try: # Reading a member to its end checks its CRC-32 (BadZipFile otherwise). with archive.open(PREFIX + row['path']) as source, partial.open('wb') as output: @@ -316,13 +325,14 @@ def import_archive(path, destination, progress=lambda **kw: None, cancelled=lamb if not data: break output.write(data); done += len(data) + digest.update(data) progress(done=done, total=total, detail=path.name) except zipfile.BadZipFile as error: raise ValueError('离线包已损坏,请重新下载:' + path.name) from error - if partial.stat().st_size != row['bytes']: + if partial.stat().st_size != row['bytes'] or digest.hexdigest() != row['sha256']: raise ValueError('离线包校验失败,已保留文件:' + path.name) partial.replace(target) - verified[row['path']] = stamp(target) + verified[row['path']] = [*stamp(target), row['sha256']] save(root / VERIFIED, verified) marker.write_bytes(value['raw']) return dict(root=plain(root), kind=value['kind'], variant=value['variant'], name=path.name, **origin) @@ -362,6 +372,25 @@ def encoder_library(runtime, config, present): return ENCODER_COPY, [copy for _, copy in rows] +def publish_without_overwrite(partial, target): + """Give a finished copy its final name, never replacing a file that appeared meanwhile.""" + try: + try: + os.link(partial, target) + except FileExistsError: + raise + except OSError: + # No hard links on this disk: renaming onto nothing is the safe way left. + if os.name != 'nt' and os.path.lexists(target): + raise FileExistsError(str(target)) + os.rename(partial, target) + return + except FileExistsError: + partial.unlink(missing_ok=True) + raise ValueError('离线安装已有不同模型,未覆盖:' + plain(target)) from None + partial.unlink(missing_ok=True) + + def assemble(runtime, models, source, progress=lambda **kw: None): """Bind imported components to the current EXE's engine, without running it.""" runtime = io_path(runtime) @@ -402,7 +431,13 @@ def place(src, target): try: os.link(src, target) except OSError: - copy_progress(src, target, file_progress(progress, 'copy_bytes', src.stat().st_size)) + # Keep an interrupted copy outside the final name. A unique staging + # name also preserves any existing neighbors the user added. + with tempfile.NamedTemporaryFile(prefix='model-copy-', suffix='.copying', + dir=target.parent, delete=False) as output: + partial = Path(output.name) + copy_progress(src, partial, file_progress(progress, 'copy_bytes', src.stat().st_size)) + publish_without_overwrite(partial, target) included = [] for row in model['files']: @@ -481,7 +516,8 @@ def finish(runtime, config, model, source, here=None): # the first start takes that instead of reading tens of GiB once more. imported, final = stamps(runtime), listing(runtime) trusted = {row['path'] for row in model['files'] + copies} - trusted |= {row['path'] for row in config['files'] if imported.get(row['path']) == final.get(row['path'])} + trusted |= {row['path'] for row in config['files'] if row['path'] in final + and imported.get(row['path']) == [*final[row['path']], row['sha256']]} save(runtime / 'engine' / 'portable-verified.json', {row['path']: dict(bytes=final[row['path']][0], mtime_ns=final[row['path']][1], sha256=row['sha256']) for row in value['files'] if row['path'] in trusted and row['path'] in final}) diff --git a/freevideo_engine/package_cleanup.py b/freevideo_engine/package_cleanup.py index 19ce3691..f7ce501d 100644 --- a/freevideo_engine/package_cleanup.py +++ b/freevideo_engine/package_cleanup.py @@ -226,8 +226,36 @@ def packages(folder): yield Path(plain(pack)), 'models', value if isinstance(value, dict) else None +def known_package_tree(pack, value): + """Retain unrecognized packages and anything added outside their inventory.""" + from .offline_packages import inventory, VERIFIED + if (not isinstance(value, dict) or value.get('schema_version') != 1 or value.get('kind') != 'models' + or not isinstance(value.get('files'), list) or not value['files']): + return False + try: + inventory(value['files']) + if any(not row['path'].startswith('models/') for row in value['files']): + return False + files = {INNER + '/' + row['path'] for row in value['files']} + files.update(INNER + '/' + name for name in (MODELS_MARKER, VERIFIED, '模型包使用说明.txt')) + folders = {parent.as_posix() for name in files for parent in Path(name).parents} + pack = io_path(pack) + def unreadable(error): + raise error + for directory, children, names in os.walk(pack, followlinks=False, onerror=unreadable): + directory = Path(directory) + if any((directory / name).relative_to(pack).as_posix() not in folders for name in children): + return False + if any((directory / name).relative_to(pack).as_posix() not in files for name in names): + return False + return True + except (OSError, ValueError, KeyError, TypeError): + return False + + def prune_packages(engine, comfy_root=None, index=None, running=None, dry_run=False): """Model package extractions the installation no longer needs.""" + from .locking import runtime_lock result = dict(released_bytes=0, removed=[], kept=[]) try: engine = checked_real_path(engine) @@ -241,32 +269,39 @@ def prune_packages(engine, comfy_root=None, index=None, running=None, dry_run=Fa installed = installed_at(engine) holders = [io_path(checked_real_path(path)) for paths in index.values() for path in paths] for folder in package_roots(engine, comfy_root): - for pack, kind, value in packages(folder): - pack = io_path(pack) - if kind == 'runtime' or inside(engine, pack): - result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='environment package')) - continue - if any(inside(path, pack) for path in holders): - result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='installed files live here')) - continue - rows = [row for row in (value or {}).get('files', []) - if isinstance(row, dict) and row.get('path') != PACK_INVENTORY - and (pack / INNER / str(row.get('path', ''))).is_file()] - covered = bool(rows) and all(row.get('sha256') in index for row in rows) - try: - stale = installed is not None and max((pack / INNER).stat().st_mtime, pack.stat().st_mtime) < installed - except OSError: - stale = installed is not None - if not covered and not stale: - result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='imported after this installation')) - continue - freed = discard(pack, folder, running, dry_run) - if freed is None: - result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='in use')) - continue - result['released_bytes'] += freed - result['removed'].append(dict(kind='offline-package', path=plain(pack), bytes=freed, - reason='installed and verified' if covered else 'not used by this installation')) + try: + with runtime_lock(io_path(folder) / 'import.lock', inherit=False): + for pack, kind, value in packages(folder): + pack = io_path(pack) + if kind == 'runtime' or inside(engine, pack): + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='environment package')) + continue + if any(inside(path, pack) for path in holders): + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='installed files live here')) + continue + if not known_package_tree(pack, value): + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='in use')) + continue + rows = [row for row in (value or {}).get('files', []) + if isinstance(row, dict) and row.get('path') != PACK_INVENTORY + and (pack / INNER / str(row.get('path', ''))).is_file()] + covered = bool(rows) and all(row.get('sha256') in index for row in rows) + try: + stale = installed is not None and max((pack / INNER).stat().st_mtime, pack.stat().st_mtime) < installed + except OSError: + stale = installed is not None + if not covered and not stale: + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='imported after this installation')) + continue + freed = discard(pack, folder, running, dry_run) + if freed is None: + result['kept'].append(dict(kind='offline-package', path=plain(pack), reason='in use')) + continue + result['released_bytes'] += freed + result['removed'].append(dict(kind='offline-package', path=plain(pack), bytes=freed, + reason='installed and verified' if covered else 'not used by this installation')) + except BlockingIOError: + result['kept'].append(dict(kind='offline-package', path=plain(folder), reason='in use')) return result diff --git a/web/launcher.js b/web/launcher.js index 63a1fdb9..836d8609 100644 --- a/web/launcher.js +++ b/web/launcher.js @@ -12,8 +12,8 @@ let configured = performance.now(); // ComfyUI restores the previous session's workflow and tabs after every // extension's setup, so that restore can bring another workflow to the front -// over ours. Once startup is quiet, bring ours back unless the user has -// already moved on. Never awaited from setup: ComfyUI restores only after +// over ours. Once startup is quiet, bring our tab back into view even if a +// click or key arrived during startup. Never awaited from setup: ComfyUI restores only after // setup returns. async function keepInFront(workflow, opened) { let touched = false; @@ -23,8 +23,11 @@ async function keepInFront(workflow, opened) { const begin = performance.now(); while (performance.now() - begin < 15000 && (app.extensionManager?.spinner || performance.now() - configured < 600)) await sleep(100); - if (studioWorkflow(app.graph) || touched) return; + // An already visible FreeVideo graph may contain edits; keep that copy. + if (studioWorkflow(app.graph)) return; const store = app.extensionManager?.workflow; + // The user kept working in our own tab: its edits stay, even without the generate node. + if (touched && opened && store?.activeWorkflow === opened) return; // Our own tab when ComfyUI still has it; a new one otherwise. const target = opened && store?.getWorkflowByPath?.(opened.path) === opened ? opened : 'FreeVideo'; await app.loadGraphData(structuredClone(workflow), true, true, target); diff --git a/web/setup.js b/web/setup.js index 79c5bbc0..6003743f 100644 --- a/web/setup.js +++ b/web/setup.js @@ -13,6 +13,7 @@ const languageOverride = typeof location !== 'undefined' const cn = languageOverride === 'zh' || (languageOverride !== 'en' && String(navigator.language || '').toLowerCase().startsWith('zh')); const t = (en, zh) => cn ? zh : en; +const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已装好;要重装请先点「检测并预览」'}; const gib = n => Number.isFinite(n) ? (n / 2 ** 30).toFixed(1) + " GiB" : "—"; const failureText = value => { const text = String(value || ''), matches = [...text.matchAll(/MODEL_MISMATCH_REPAIR: ([^\r\n]+)/g)]; @@ -260,8 +261,9 @@ export async function openSetup() { const controls = () => { for (const e of [root, extra, copy, samplingCaches, saveFolders, inspect, use, hfToken, applyToken, clearToken]) e.disabled = busy; cancel.disabled = !busy; install.disabled = busy || !planId || !accept.checked; accept.disabled = busy; inspect.classList.toggle("fv-primary", !planId); install.classList.toggle("fv-primary", Boolean(planId)); - const why = busy || (ready && !planId) ? "" : planStale ? t("Settings changed. Click Detect & review again to update the plan.", "设置已更改,请重新点击「检测并预览」更新安装计划。") + const why = busy ? "" : planStale ? t("Settings changed. Click Detect & review again to update the plan.", "设置已更改,请重新点击「检测并预览」更新安装计划。") : planErrors ? t("The plan has problems shown in red above. Please resolve them, then click Detect & review again.", "安装计划中有标红的问题,请解决后重新点击「检测并预览」。") + : ready && !planId ? t(INSTALLED_HINT.en, INSTALLED_HINT.zh) : !planId ? t("Click Detect & review first. Install / repair becomes available once the plan is ready.", "请先点击「检测并预览」,生成安装计划后即可安装。") : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,即可点击「安装/修复」。") : ""; blocked.textContent = why; blocked.hidden = !why; diff --git a/web/updates.js b/web/updates.js index 0475c13b..b40b70d5 100644 --- a/web/updates.js +++ b/web/updates.js @@ -3,6 +3,7 @@ import { api } from '../../scripts/api.js'; // Every platform downloads from one release: the latest vX.Y.Z. const releasePage = 'https://github.com/FlashML-org/FreeVideo/releases/latest'; const storageKey = 'freevideo.dismissed-update'; +const CANCEL_FAILURE = {en: 'Cancellation failed. Please use the launcher.', zh: '取消没成功,请在启动器里操作'}; const listeners = new Set(); // Launcher phases during which the server may disappear and come back updated. const working = new Set(['checking', 'downloading', 'waiting', 'restarting', 'engine']); @@ -80,10 +81,17 @@ export async function applyUpdate() { } export async function cancelUpdate() { - try { - const response = await api.fetchApi('/freevideo/updates/cancel', {method: 'POST', cache: 'no-store', signal: AbortSignal.timeout(10000)}); - if (response.ok) updating = false; - } catch { /* The launcher keeps its state; the next poll shows it. */ } + failure = ''; + for (let attempt = 0; attempt < 2; attempt++) { + try { + const response = await api.fetchApi('/freevideo/updates/cancel', {method: 'POST', cache: 'no-store', signal: AbortSignal.timeout(10000)}); + if (!response.ok) throw new Error(response.statusText); + updating = false; + break; + } catch { + if (attempt === 1) failure = 'cancel'; + } + } publish(); lastCheck = -Infinity; later(800); @@ -144,7 +152,9 @@ export function createUpdateNotice(cn) { const model = launcher?.model || ''; const busy = updating && (working.has(phase) || !!model || offline); let text = '', actions = false; - if (busy) { + if (failure === 'cancel') { + text = t(CANCEL_FAILURE.en, CANCEL_FAILURE.zh); + } else if (busy) { text = model === 'switching' || (model && offline) ? t('Switching to the faster model… ComfyUI restarts once and this page reconnects by itself.', '正在切换到更快的模型…ComfyUI 会重启一次,页面会自动恢复。') : model === 'waiting' ? t('The faster model is ready. FreeVideo switches after the current video; ComfyUI restarts once.', '更快的模型已下载好,当前视频生成完后自动切换,ComfyUI 会重启一次。') From 82761e618597e66f84151e4e52ce854a23ada8bb Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:45:19 +0000 Subject: [PATCH 04/53] Agent tools no longer stall on the first call on Windows --- freevideo_engine/agent/mcp.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/freevideo_engine/agent/mcp.py b/freevideo_engine/agent/mcp.py index cbe9969d..321e363f 100644 --- a/freevideo_engine/agent/mcp.py +++ b/freevideo_engine/agent/mcp.py @@ -108,6 +108,19 @@ def short_drama(arguments): Use ref to replay writes safely. Ordinary job batches remain available through freevideo_submit_batch.""" +def preload(): + """Load what tool calls import before stdin is read. + + Tool calls run on threads while this thread waits on stdin. On Windows a DLL loaded for the first + time then (numpy's OpenBLAS, loaded through spec) can wait for the client's next line, so the first + tool call would stall.""" + from . import errors, schema, spec # noqa: F401 + try: + from PIL import Image # noqa: F401 + except ImportError: + pass + + class Server: def __init__(self, client, out=None): self.client = client @@ -129,6 +142,7 @@ def serve(self, stream=None): self.client.hello(via='mcp') except (ApiError, AttributeError): pass # Starting before FreeVideo is allowed; later authenticated requests mark it online. + preload() for line in stream: line = line.strip() if not line: From 7dca263afe5c252b42332a5970185af67e4c9cb4 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:45:20 +0000 Subject: [PATCH 05/53] Open or create editing projects from the view switch --- web/editor.js | 2 +- web/view_navigation.js | 31 +++++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/web/editor.js b/web/editor.js index 97df74e7..d911f317 100644 --- a/web/editor.js +++ b/web/editor.js @@ -1012,7 +1012,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given }); const leaving = () => { player.pause(); rememberView(); if (queue.dirty) queue.flush(); }; window.addEventListener('pagehide', leaving); - const handle = {element, state, actions, store, adjust, audio, + const handle = {element, state, actions, store, adjust, audio, notify, async switchView(view) { if (closing || disposed || view === 'editor') return; if (await handle.close() !== false) await onView(view); diff --git a/web/view_navigation.js b/web/view_navigation.js index f9f375d9..c2359147 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -64,7 +64,7 @@ export function refreshNavigation() { if (!toolbar) return; const nodes = (app.graph?._nodes || []).filter(n => n.comfyClass === 'FreeVideoGenerate' || n.type === 'FreeVideoGenerate'); if (!nodes.includes(selected)) selected = nodes[0]; - toolbar.hidden = !selected || currentView !== 'nodes'; + toolbar.hidden = !app.graph || currentView !== 'nodes'; for (const button of toolbar.querySelectorAll('[data-view]')) { button.setAttribute('aria-pressed', String(button.dataset.view === currentView)); } @@ -89,14 +89,33 @@ export function installNavigation(openStudio, agent = false) { // The whole bar drags like a window; these dots say so on hover. const grip = document.createElement('span'); grip.className = 'fv-view-grip'; grip.setAttribute('aria-hidden', 'true'); const brand = wordmark(); - const choose = action => { - const focused = Object.values(app.canvas?.selected_nodes || {}).find(n => n.comfyClass === 'FreeVideoGenerate' || n.type === 'FreeVideoGenerate'); - refreshNavigation(); if (focused || selected) return action(focused || selected); + const choose = (action, create = false) => { + const focused = Object.values(app.canvas?.selected_nodes || {}).find(n => (app.graph?._nodes || []).includes(n) + && (n.comfyClass === 'FreeVideoGenerate' || n.type === 'FreeVideoGenerate')); + refreshNavigation(); + let node = focused || selected, created = false; + if (!node && create) { + try { + node = globalThis.LiteGraph?.createNode('FreeVideoGenerate'); + if (!node) throw new Error('FreeVideo node unavailable'); + app.graph.add(node); app.graph.change?.(); + selected = node; created = true; + } catch (error) { + console.warn('FreeVideo edit project:', error); + app.extensionManager?.toast?.add({severity: 'error', summary: 'FreeVideo', + detail: t('Could not complete. Try again.', '没有完成,请重试'), life: 9000}); + return null; + } + } + if (node) return action(node, created); }; const settings = document.createElement('button'); settings.type = 'button'; settings.textContent = t('Settings', '设置'); settings.className = 'fv-view-settings'; settings.onclick = openSetup; - row.append(grip, brand, viewSwitch('nodes', openCreativeWorkspace, () => {}, () => choose(node => - openEditorView(node)), () => choose(node => openAutopilot(node))), createVersionInfo(cn).element, settings); toolbar.append(row); + row.append(grip, brand, viewSwitch('nodes', () => choose(node => { open?.(node); }, true), () => {}, () => choose(async (node, created) => { + const handle = await openEditorView(node); + if (created) handle?.notify(t('Created an editing project for you.', '已为你新建一个剪辑工程')); + return handle; + }, true), () => choose(node => openAutopilot(node), true)), createVersionInfo(cn).element, settings); toolbar.append(row); let shown = false; try { shown = localStorage.getItem('freevideo.view-guide') === '1'; } catch { /* Show once this session. */ } if (!shown) { From a62d123a815faab7ae2073c311b1300984db8933 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:45:59 +0000 Subject: [PATCH 06/53] Agent listening job: a forgotten subtitle job fails as listen_failed instead of a generic error --- freevideo_engine/agent/projects.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/freevideo_engine/agent/projects.py b/freevideo_engine/agent/projects.py index ddcb13e7..02a5a173 100644 --- a/freevideo_engine/agent/projects.py +++ b/freevideo_engine/agent/projects.py @@ -534,13 +534,17 @@ def run_listen(port, row, *, poll=.25): raise JobError('busy', 'The subtitle queue is full; retry shortly') from error identity, cancelled = view['id'], False row['_phase'] = 'listening' - while view['state'] == 'running': - if row.get('_cancel') and not cancelled: - view, cancelled = queue.cancel(identity), True - continue - row['_detail'] = json.dumps(dict(fraction=round(view['fraction'], 3))) - time.sleep(poll) - view = queue.get(identity) + try: + while view['state'] == 'running': + if row.get('_cancel') and not cancelled: + view, cancelled = queue.cancel(identity), True + continue + row['_detail'] = json.dumps(dict(fraction=round(view['fraction'], 3))) + time.sleep(poll) + view = queue.get(identity) + except KeyError as error: + # The page's queue forgot this job (its record cap recycles finished ones) before the result was read. + raise JobError('listen_failed', 'Listening finished but the subtitle service no longer has the result; retry') from error if view['state'] == 'cancelled': raise JobError('cancelled', 'Cancelled') if view['state'] != 'done': From a5c69dcf33bc62e5b0bc72d96df349c012cf7ccf Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:46:00 +0000 Subject: [PATCH 07/53] Smooth timeline playback with media clocks and ready handoffs --- web/editor_frame.js | 6 +- web/editor_player.js | 141 ++++++++++++++++++++++++++++------ web/editor_preview_picture.js | 21 ++++- 3 files changed, 141 insertions(+), 27 deletions(-) diff --git a/web/editor_frame.js b/web/editor_frame.js index 915e6825..614d15a7 100644 --- a/web/editor_frame.js +++ b/web/editor_frame.js @@ -66,7 +66,11 @@ export function createPictureGuide(host, frame, {state, t}) { const unsubscribe = session.subscribe(updateTools); updateTools(); return {update({size, record, clip, preview, playing}) { - current = clip; view = {size, record, clip, preview}; + // The picture renderer owns the final dimensions. Do not overwrite them + // with the source size on each transport tick and force layout again. + if (view && view.record === record && view.clip === clip && view.preview === preview && view.playing === playing + && view.size.width === size.width && view.size.height === size.height) return; + current = clip; view = {size, record, clip, preview, playing}; const show = !preview && !!clip?.source && differentPictureShape(size, record); const ratio = size.width / size.height, target = record.width / record.height; frame.style.setProperty('--fv-ed-width', String(size.width)); frame.style.setProperty('--fv-ed-height', String(size.height)); diff --git a/web/editor_player.js b/web/editor_player.js index c3108391..0524d7ca 100644 --- a/web/editor_player.js +++ b/web/editor_player.js @@ -6,7 +6,8 @@ import {sourceKey, mediaURL} from './editor_store.js'; import {clipPictureSize, createPictureGuide} from './editor_frame.js'; import {createPlaybackClock} from './editor_transport_model.js'; import {createAudioPreview} from './editor_audio.js'; -import {previewSpeed, speedSeek, speedAudioFrame, separateSpeedAudio} from './editor_speed.js'; +import {previewSpeed, speedAudioFrame, separateSpeedAudio} from './editor_speed.js'; +import {sourceFrame, percent} from './editor_speed_model.js'; const el = (tag, cls) => { const e = document.createElement(tag); if (cls) e.className = cls; return e; }; export function audioSeconds(clip, sourceFrame) { // The chain receipt counts samples at 32 kHz, even if the media is 48 kHz. @@ -49,7 +50,7 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err let active = 0, clipId = null, playing = false, disposed = false, raf = null, clockStart = 0, frameStart = 0, ticking = false, preview = null, previewSize = null, previewProject = null; let audioContext = null, textRenderer = null, chains = [], chainSignature = null, chainRequest = null, chainEpoch = 0; const failedChains = new Set(), entries = new Map(); - let seekGeneration = 0; + let seekGeneration = 0, forcePosition = false, pictureView = null, processedSound = false; const chainSeconds = (chain, position) => Math.max(0, (position - chain.start) / 24 + (chain.offset || 0)); const playback = createPlaybackClock(); const gains = new WeakMap(), volumePreview = new Map(); @@ -66,21 +67,55 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err } media.volume = Math.min(1, Math.max(0, value)); } - function seekMedia(media, url, seconds, running, gain = 1) { + function startMedia(media) { + if (disposed || !media._editorRunning || !media.paused || media.seeking || media.readyState < 3 || media._editorPlay || media._editorPlayFailed) return; + const attempt = {url: media.dataset.source}; media._editorPlay = attempt; + const current = () => !disposed && media._editorRunning && media._editorPlay === attempt && media.dataset.source === attempt.url; + // A rejected play() is usually an interruption (a seek or load in flight), so the clip + // holds off for a moment and then tries again; only a source change or stop resets it sooner. + const rejected = () => { + if (!current()) return; + media._editorPlay = null; media._editorPlayFailed = true; + setTimeout(() => { + if (disposed || media.dataset.source !== attempt.url) return; + media._editorPlayFailed = false; startMedia(media); + }, 120); + }; + try { + Promise.resolve(media.play()).then(() => { + if (!current()) { if (disposed || !media._editorRunning) media.pause(); return; } + media._editorPlay = null; + }, rejected); + } catch { rejected(); } + } + function seekMedia(media, url, seconds, running, gain = 1, force = forcePosition) { media.playbackRate *= Math.max(1, playback.rate); if (media.tagName === 'AUDIO') media.muted ||= playback.rate < 0; running = running && playback.rate > 0; const changed = media.dataset.source !== url; - if (changed) { media.pause(); media.dataset.source = url; media.src = url; media.load(); } + if (changed) { stopMedia(media); media.dataset.source = url; media.src = url; media._editorPlayFailed = false; media.load(); } + if (running && !media._editorRunning) media._editorPlayFailed = false; media._editorTime = Math.max(0, seconds); media._editorRunning = running; volume(media, gain); - if (media.readyState >= 1 && (speedSeek(media) || changed || !running || Math.abs(media.currentTime - seconds) > .08)) { - try { media.currentTime = Math.max(0, seconds); } catch {} + const drift = Math.abs(media.currentTime - media._editorTime); + // Forward speed uses the native media clock. Exact paused/reverse frames + // and explicit seeks still position precisely; ticks never cancel a seek. + if (media.readyState >= 1 && (force || !media.seeking) && drift > (changed || force || !running ? .000001 : .08)) { + try { media.currentTime = media._editorTime; } catch {} } - if (running) { if (media.paused) media.play().catch(() => {}); } else media.pause(); + if (running) startMedia(media); else stopMedia(media); } - function stopMedia(media) { media.pause(); media._editorRunning = false; delete media.dataset.audioChain; } + function stopMedia(media) { if (!media.paused || media._editorPlay) media.pause(); media._editorPlay = null; media._editorRunning = false; delete media.dataset.audioChain; } function metadataHandler(media) { - media.onloadedmetadata = () => { if (disposed) return; try { media.currentTime = media._editorTime || 0; } catch {} if (media._editorRunning) media.play().catch(() => {}); }; + media.onloadedmetadata = () => { + if (disposed) return; + if (!media.seeking && Math.abs(media.currentTime - (media._editorTime || 0)) > .000001) { + try { media.currentTime = media._editorTime || 0; } catch {} + } + startMedia(media); + }; + const ready = () => { startMedia(media); if (media === videos[active]) showPicture(); }; + media.addEventListener('seeked', ready); + media.addEventListener('canplay', ready); } [...videos, ...audio].forEach(metadataHandler); function failChain(media) { @@ -218,6 +253,16 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err } return result; } + function showPicture() { + if (disposed || !pictureView) return; + const {guide, picture} = pictureView, video = picture.video; + pictureGuide.update(guide); + // Keep the outgoing decoded picture while an incoming part is loading + // or seeking. Its slot is recycled only after the new picture is shown. + if (picture.clip && picture.playing && !picture.preview && (video.seeking || video.readyState < 3)) return; + videos.forEach(value => value.classList.toggle('fv-ed-visible', !!picture.clip && value === video)); + picturePreview.update(picture); + } function render() { if (disposed) return; refreshChains(); @@ -239,17 +284,18 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err const source = clip?.source && sources.get(sourceKey(clip.source)); const hasPicture = !!source?.video && !source.missing; const fallbackSize = record.size_mode === 'fixed' || record.tracks[0].clips.some(c => c.source) ? record : {width: 1344, height: 768}; - pictureGuide.update({size: previewSize || (hasPicture && clipPictureSize(clip, sources)) || fallbackSize, record, clip, - preview: !!preview, playing}); + const guide = {size: previewSize || (hasPicture && clipPictureSize(clip, sources)) || fallbackSize, record, clip, + preview: !!preview, playing}; if (!hasPicture) { clipId = null; videos.forEach(video => { video.classList.remove('fv-ed-visible'); stopMedia(video); }); } else if (clipId !== clip.id) { const url = mediaURL(api, source.video); - if (videos[1 - active].dataset.source === url) active = 1 - active; + if (clipId !== null || videos[1 - active].dataset.source === url) active = 1 - active; clipId = clip.id; } const linked = hasPicture ? [...map.values()].find(row => row.clip.id === clip.link) : null; const processedAudio = audioPreview.render({record, position, playing: playing && playback.rate > 0, playbackRate: playback.rate}); + processedSound = !!processedAudio; // The joined sound is the plain forward path only: sound edits, changed speed and // backward playback keep their own routes and the original sound. const plainSpeed = id => !separateSpeedAudio(model.find(record, id)); @@ -261,10 +307,13 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err const separate = originalSeparate || linkedChain; if (hasPicture) { const video = videos[active], speedPlayback = previewSpeed(video, clip, playing, linked?.clip); video.muted = !linked || separate || playback.rate < 0 || speedPlayback.muted; + video._timelineClip = clip; + delete video.dataset.preparedClip; seekMedia(video, mediaURL(api, source.video), (videoRow.frame + (playing && playback.rate > 0 ? 0 : .5)) / 24, speedPlayback.running && map.get('v1')?.clip.id === clip.id, clipVolume(linked?.clip)); - videos.forEach((value, index) => { value.classList.toggle('fv-ed-visible', index === active); if (index !== active) stopMedia(value); }); + videos.forEach((value, index) => { if (index !== active) stopMedia(value); }); } - picturePreview.update({clip: hasPicture ? clip : null, size: hasPicture && clipPictureSize(clip, sources), video: videos[active], preview: !!preview, playing}); + pictureView = {guide, picture: {clip: hasPicture ? clip : null, size: hasPicture && clipPictureSize(clip, sources), video: videos[active], preview: !!preview, playing}}; + showPicture(); let slot = 0; const usedChains = new Set(); for (const [track, row] of map) { @@ -283,6 +332,7 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err if (carriedByVideo || slot >= audio.length) continue; const url = mediaURL(api, metadata.audio); if (!url) continue; const media = audio[slot++], speed = previewSpeed(media, row.clip, playing); delete media.dataset.audioChain; media.muted = speed.muted; + media._timelineClip = row.clip; seekMedia(media, url, audioSeconds(row.clip, speedAudioFrame(row.clip, shown - row.clip.start)), speed.running, clipVolume(row.clip)); } while (slot < audio.length) stopMedia(audio[slot++]); @@ -295,19 +345,61 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err } const next = record.tracks[0].clips.find(value => value.start >= (clip ? clip.start + clip.frames : position) && value.source); const metadata = next && sources.get(sourceKey(next.source)); - if (metadata?.video && !metadata.missing) { + if (metadata?.video && !metadata.missing && (!hasPicture || videos[active].matches('.fv-ed-visible'))) { const idle = videos[1 - active]; idle.muted = true; - if (idle.dataset.source !== mediaURL(api, metadata.video)) seekMedia(idle, mediaURL(api, metadata.video), next.in / 24, false); + const url = mediaURL(api, metadata.video), seconds = sourceFrame(next, 0) / 24; + const prepared = JSON.stringify([next.id, url, seconds]); + if (idle.dataset.preparedClip !== prepared) { + seekMedia(idle, url, seconds, false, 1, true); idle.dataset.preparedClip = prepared; + } } textRenderer?.(); + forcePosition = false; + } + function mediaFrame() { + // Edited sound and reverse playback retain their existing clocks. For + // native forward playback, wall time must not outrun emitted media and + // force repeated seeks or a false joined-sound drift failure. + if (playback.rate !== 1 || processedSound) return null; + const usable = media => media._editorRunning && !media.error && !media._editorPlayFailed && typeof media.duration === 'number'; + const ready = media => media.readyState >= 3 && !media.seeking && (!media.paused || media.ended); + for (const media of chainAudio.values()) if (usable(media) && ['starting', 'active'].includes(media._attempt?.mode) + && entries.get(media._chain.track) === media._attempt && ready(media)) + return media.ended ? media._chain.start + media._chain.frames + : media._chain.start + (media.currentTime - (media._chain.offset || 0)) * 24; + const original = [...audio.filter(media => !media.muted), videos[active]]; + for (const media of original) { + const clip = media._timelineClip; + if (!usable(media) || !clip || clip.reverse) continue; + // A shortened proxy or stale duration receipt must not pin the + // transport forever beyond the media's physical end. + if (media._editorTime >= media.duration && media.currentTime < media._editorTime - .001) return null; + if (!ready(media)) continue; + const start = media.tagName === 'AUDIO' ? audioSeconds(clip, clip.in) : clip.in / 24; + if (start < 0) continue; + const position = clip.start + ((media.currentTime - start) * 24 - (clip.speed_phase || 0) / 100) / (percent(clip) / 100); + return media.ended && position < clip.start + clip.frames - .01 ? null : position; + } + // A native decoder buffering at a new entry keeps its requested frame. + // Failed or missing media continues through the ordinary fallback. + return original.some(usable) ? state.playhead : null; } function tick(now) { if (!playing || disposed) return; + const emitted = mediaFrame(); + if (emitted !== null && Number.isFinite(emitted)) { + clockStart = now; frameStart = Math.max(state.playhead, Math.floor(emitted + .0001)); playback.reset(clockStart, frameStart); + } const total = model.exportFrames(state.record), {frame: position, stop} = playback.advance(now, total); const target = stop && playback.rate > 0 && !playback.range ? Math.max(0, total - 1) : position; - // A loop that starts over is a jump like a user's: the joined sound starts again from there. // A loop that starts over is a jump like a user's: the joined sound and the clock start again from there. - ticking = (target - state.playhead) * playback.rate >= 0; state.seek(target); ticking = false; render(); + const previous = state.playhead, start = Math.min(total, playback.range?.start ?? 0), end = Math.min(total, playback.range?.end ?? total); + const raw = frameStart + Math.floor(Math.max(0, now - clockStart) * 24 / 1000) * playback.rate; + const wrapped = playback.loop && end > start && (playback.rate > 0 ? raw >= end : raw < start); + ticking = !wrapped && (target - previous) * playback.rate >= 0; state.seek(target); ticking = false; + if (wrapped && target === previous) { + resetEntries(); forcePosition = true; render(); clockStart = performance.now(); frameStart = target; playback.reset(clockStart, frameStart); + } if (stop) { pause(); return; } raf = requestAnimationFrame(tick); } @@ -319,20 +411,23 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err clearPreview(); const total = model.exportFrames(state.record), restart = !playback.range && !playback.loop && playback.rate > 0 && state.playhead >= Math.max(0, total - 1); state.seek(playback.begin(restart ? 0 : state.playhead, total)); - resetEntries(); playing = true; clockStart = performance.now(); frameStart = state.playhead; render(); raf = requestAnimationFrame(tick); + resetEntries(); playing = true; frameStart = state.playhead; render(); + // Preview preparation can include a decode and layout. Start the clock + // after that work so its cost is not charged as already-played frames. + clockStart = performance.now(); raf = requestAnimationFrame(tick); playback.reset(clockStart, frameStart); } - function pause() { playing = false; playback.rate = 1; resetEntries(); cancelAnimationFrame(raf); allMedia().forEach(stopMedia); render(); } + function pause() { playing = false; playback.rate = 1; resetEntries(); forcePosition = true; cancelAnimationFrame(raf); allMedia().forEach(stopMedia); render(); } playButton.onclick = () => playing ? pause() : play(); const unsubscribe = state.subscribe(event => { if (event.type === 'selection') { if (pictureEdit.panelOpen && pictureEdit.selected()) clearPreview(); render(); return; } if (event.type !== 'playhead' && event.type !== 'record') return; if (preview && preservePreview()) previewProject = pictureLayout(state.record); else if (event.type === 'playhead' || previewProject !== pictureLayout(state.record)) clearPreview(); - if (!ticking) resetEntries(); - if (playing && !ticking) { clockStart = performance.now(); frameStart = state.playhead; } - if (playing && !ticking) playback.reset(clockStart, frameStart); + if (!ticking) { resetEntries(); forcePosition = true; } + if (playing && !ticking) frameStart = state.playhead; render(); + if (playing && !ticking) { clockStart = performance.now(); playback.reset(clockStart, frameStart); } }); const stopPictureEdit = pictureEdit.subscribe(() => { // Draft geometry updates in picturePreview; only a changed preview part diff --git a/web/editor_preview_picture.js b/web/editor_preview_picture.js index 2472f03a..6c69e77a 100644 --- a/web/editor_preview_picture.js +++ b/web/editor_preview_picture.js @@ -74,7 +74,7 @@ export function createPreviewPicture(host, frame, {state, t}) { frame.prepend(bed); frame.append(shade, handles, cropBox, ...lines); host.append(outside); let current = null, layout = null, gesture = null, key = '', disposed = false, observedVideo = null, videoCallback = null; - let followFrame = 0; + let followFrame = 0, inputs = null, drawn = null; const samples = [], layouts = []; metrics.set(state, {draw: samples, layout: layouts}); const recordTime = (list, start) => { list.push(performance.now()-start); if(list.length>240)list.shift(); }; // No part under the playhead (a gap, or past the end) is never the selected one. @@ -84,13 +84,14 @@ export function createPreviewPicture(host, frame, {state, t}) { function reset() { for (const node of [bed, outside, shade, handles, cropBox, ...lines]) node.hidden = true; shade.style.display = 'none'; - frame.classList.remove('fv-ed-picture-adjusted'); layout = null; key = ''; + frame.classList.remove('fv-ed-picture-adjusted'); layout = null; key = ''; inputs = drawn = null; frame.querySelectorAll('video').forEach(v => { if (!v.matches('.fv-ed-source-preview')) v.removeAttribute('style'); }); } function watch(video) { if (observedVideo === video) return; if (observedVideo) { observedVideo.cancelVideoFrameCallback?.(videoCallback); observedVideo.removeEventListener('seeked', draw); observedVideo.removeEventListener('loadeddata', draw); } observedVideo = video; + key = ''; drawn = null; if (video) { video.addEventListener('seeked', draw); video.addEventListener('loadeddata', draw); const next = () => { if (disposed || observedVideo !== video) return; draw(); videoCallback = video.requestVideoFrameCallback?.(next); }; @@ -102,6 +103,15 @@ export function createPreviewPicture(host, frame, {state, t}) { current = value; if (!value?.clip || !value.size || value.preview || !value.video) { if (session.draft?.mode === 'crop') session.cancel(); watch(null); reset(); return; } if (value.playing && session.draft && session.draft.mode !== 'slider') session.cancel(); + const nextInputs = [value.clip, state.record, session.draft, state.selection, value.video, value.video.dataset.source, + value.size.width, value.size.height, !!value.playing, session.output(value.clip)]; + // ResizeObserver and the transition follower invalidate geometry. A + // transport tick alone needs neither style writes nor a layout read. + if (!force && layout && inputs && nextInputs.every((v, i) => v === inputs[i])) { + if (!value.video.requestVideoFrameCallback) draw(); + return; + } + inputs = nextInputs; watch(value.video); const clip = {...PICTURE_DEFAULTS, ...session.clip(value.clip)}, record = state.record; const cropping = session.draft?.mode === 'crop' && session.draft.id === clip.id; @@ -125,7 +135,7 @@ export function createPreviewPicture(host, frame, {state, t}) { // The Fill/Fit switch sits on the preview area (editor_frame.js shows or hides it); here it only turns gray while the part cannot change. const tools = host.querySelector('.fv-ed-picture-tools'); if (tools) for (const button of tools.querySelectorAll('button')) button.disabled = !session.canEdit(clip); - const nextKey = JSON.stringify([clip, policy, rect.width, rect.height, hostRect.width, hostRect.height, isSelected(clip), value.playing]); + const nextKey = JSON.stringify([clip, policy, value.video.dataset.source, rect.width, rect.height, hostRect.width, hostRect.height, isSelected(clip), value.playing]); if (!force && nextKey === key) { recordTime(layouts, started); return; } key = nextKey; let view = p; if (policy.mode === 'source') { @@ -181,6 +191,11 @@ export function createPreviewPicture(host, frame, {state, t}) { function draw() { if (!layout || !current?.video || current.video.readyState < 2 || !current.video.videoWidth || !current.video.videoHeight || (bed.hidden && outside.hidden)) return; const start = performance.now(), {p, bw, bh, m, left, top, hw, hh} = layout, video = current.video; + // loadeddata, seeked and the frame callback can describe the same frame. + // Keep the existing blur resolution/filter, but rasterize it only once. + const mediaTime = video.currentTime; + if (drawn?.video === video && drawn.layout === layout && drawn.time === mediaTime) return; + drawn = {video, layout, time: mediaTime}; const cropping = session.draft?.mode === 'crop'; if (!bed.hidden) { const w = Math.max(1, Math.ceil(bw/4)), h = Math.max(1, Math.ceil(bh/4)); From 1002fb8ba3ce734707673d7897f84d50bfe76d0e Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:46:01 +0000 Subject: [PATCH 08/53] Add first-open editor guide with grouped replay --- web/editor.js | 12 ++- web/editor_genbar.js | 1 + web/editor_tracks.js | 26 ++++++- web/first_run_guide.css | 1 + web/first_run_guide.js | 169 +++++++++++++++++++++++++++++----------- web/view_navigation.js | 2 + 6 files changed, 163 insertions(+), 48 deletions(-) diff --git a/web/editor.js b/web/editor.js index d911f317..eacb1a8b 100644 --- a/web/editor.js +++ b/web/editor.js @@ -22,6 +22,7 @@ import {createProgressConnection} from './progress_connection.js'; import {createSelectionActions, deletionStatus, isMultiple, isTyping, playheadIds, removableGaps, removeGaps} from './editor_select.js'; import {isolateDialogKeys, topDialog} from './dialog_keys.js'; import {createBlockNotes, describedTrack} from './editor_notes.js'; +import {createFirstRunGuide} from './first_run_guide.js'; const editorStyles = Promise.all(['editor.css', 'editor_tracks.css', 'editor_frame.css', 'editor_adjust.css', 'editor_adjust_picture.css', 'editor_preview_picture.css', 'editor_select.css', 'editor_menu.css', 'editor_text.css', 'editor_notes.css', 'editor_transport.css'].map(name => { const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = new URL(name, import.meta.url).href; const ready = 'sheet' in style ? new Promise(resolve => { style.onload = style.onerror = resolve; }) : Promise.resolve(); @@ -115,6 +116,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const top = el('header', 'fv-header fv-ed-top'), libraryHost = el('aside', 'fv-ed-library'), preview = el('section', 'fv-ed-preview'), transport = el('div', 'fv-ed-transport'), genHost = el('div', 'fv-ed-genbar'), tracksHost = el('div', 'fv-ed-tracks'), zoomHost = el('div', 'fv-ed-zoom'); const adjustHost = el('aside', 'fv-ed-adjust-host'); + tracksHost.dataset.fvGuide = 'editor-timeline'; const button = (label, action, cls = '') => { const value = el('button', cls, label); value.type = 'button'; value.onclick = action; return value; }; const navigation = viewSwitch('editor', () => handle.switchView('studio'), () => handle.switchView('nodes'), () => {}, () => handle.switchView('autopilot')); for (const control of navigation.querySelectorAll('button')) control.removeAttribute('title'); @@ -159,7 +161,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given let library = null, closing = false, closePromise = null, exportSnapshot = null, lastSeed = null, projectEpoch = 0; let disposed = false, suppressSave = false, savedContent = '', initializing = true, tracks = null, genbar = null, adjustPanel = null, exportStatus = null, pointerEditing = false, deferredStamp = null; let capability = {maxSeconds: 15, maxFollowSeconds: 15, maxRequestFrames: null, estimate: () => null}, generationBusy = false, switching = false, generationDone = null, finishGeneration = null; - let noticeTimer = null, stopExport = null, mostRecentJob = null; + let noticeTimer = null, stopExport = null, mostRecentJob = null, guide = null; const jobWatchers = new Map(), progress = new Map(), jobStatuses = new Map(), runProgress = new Map(); let blockStates = null, progressObserver = null, progressConnection = null; function barJob(job) { @@ -969,6 +971,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (topDialog() === element && !dismissPopup(event)) state.select(null, {history: true}); }); const stopKeys = isolateDialogKeys(element, (event, inside) => { + if (guide?.handleKey(event)) return; if (event.type === 'keyup') { transportControls.keyboard(event); return; } if (event.type !== 'keydown') return; if (event.isComposing || event.keyCode === 229) return; @@ -1020,6 +1023,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given async close() { if (closePromise) return closePromise; textTools.flush(); + guide?.suspend(); closing = true; element.inert = true; player.pause(); closePromise = (async () => { await handle.ready; @@ -1030,7 +1034,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given saveWarning.textContent = t('Not saved yet. Switching again will lose recent changes.', '还没保存好,再切换会丢掉最近的改动'); saveWarning.hidden = false; return false; } - rememberView(); disposed = true; transportControls.dispose(); player.dispose(); + rememberView(); disposed = true; guide?.dispose(); transportControls.dispose(); player.dispose(); textTools.dispose(); adjust.dispose(); notes.dispose(); pictureEdit.dispose(); audio.dispose(); listeners.splice(0).forEach(remove => remove()); @@ -1150,5 +1154,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given for (const host of [tracksHost, toolbar]) host.classList.remove('fv-ed-pending'); if (!closing && !disposed) { player.refresh(); updateTop(); updateTransport(); } }); + if (typeof MutationObserver !== 'undefined') guide = createFirstRunGuide({dialog: element, api, + cn: String(language()).toLowerCase().startsWith('zh'), group: 'editor', + isBusy: () => initializing || !tracks || genHost.inert || pointerEditing || jobActive() || ['running', 'queued'].includes(exportStatus?.state), + targets: {'editor-trim': () => tracks?.guideTarget('trim'), 'editor-empty': () => tracks?.guideTarget('empty')}}); updateTop(); return handle; } diff --git a/web/editor_genbar.js b/web/editor_genbar.js index b8281140..0a07aefa 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -13,6 +13,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { note.setAttribute('role', 'status'); note.hidden = true; const input = el('input', 'fv-ed-description'); input.type = 'text'; input.value = text; const generate = el('button', 'fv-ed-generate'), cancel = el('button', null, t('Cancel', '取消')), resume = el('button', null, t('Continue', '继续')); + input.dataset.fvGuide = 'editor-describe'; generate.dataset.fvGuide = 'editor-generate'; for (const button of [generate, cancel, resume]) button.type = 'button'; host.append(hint, note, input, generate, cancel, resume); const firstFrame = createFirstFrameSwitch(host, {state, t, actions}); diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 4f8b297c..0ca06880 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -424,6 +424,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const status = create('span', 'fv-ed-clip-status'); const left = create('span', 'fv-ed-trim-handle fv-ed-trim-start'); left.dataset.edge = 'start'; const right = create('span', 'fv-ed-trim-handle fv-ed-trim-end'); right.dataset.edge = 'end'; + if (!isText(clip)) right.dataset.fvGuide = 'editor-trim'; node.append(media, fill, status, left, right); const item = {element: node, media, fill, status, clip, kind: track.kind, component: null, waveKey: undefined}; if (blockProgress && !isText(clip)) { @@ -453,6 +454,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = let row = rows.get(track.id); if (!row) { const node = create('div', 'fv-ed-track-row'); node.dataset.track = track.id; + if (track.id === 'v1') node.dataset.fvGuide = 'editor-empty'; const empty = create('div', 'fv-ed-track-empty'); node.append(empty); const head = create('div', 'fv-ed-track-head'); head.dataset.track = track.id; @@ -1132,7 +1134,29 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = showInsertion(null); insertion.classList.remove('fv-ed-blocked-insertion'); }, 700); } - return {element, keyboard, menu, dismissPopup, flashBlocked, seekPlayhead, extendClip, + function guideTarget(kind) { + const viewport = scroll.getBoundingClientRect(); + const reveal = (left, width) => () => { + if (left < viewport.left + 12 || left + width > viewport.right - 12) + scroll.scrollLeft = Math.max(0, scroll.scrollLeft + left - viewport.left - 12); + }; + if (kind === 'trim') { + const item = [...clips.values()].filter(item => item.clip.source && !isText(item.clip)) + .sort((a, b) => (b.track === 'v1') - (a.track === 'v1') || b.clip.start - a.clip.start)[0]; + if (!item) return null; + const node = item.element.querySelector('.fv-ed-trim-end'); + const r = item.element.getBoundingClientRect(), left = r.right - 12; + return {node, rect: {left, right: left + 24, top: r.top, bottom: r.bottom, width: 24, height: r.height}, reveal: reveal(left, 24)}; + } + const node = rows.get('v1')?.element; + if (!node) return null; + // Leave a small gap beyond all tracks, outside the trim hit area. At + // frame zero an empty project starts exactly at the timeline origin. + const end = model.duration(state.record), start = end ? Math.ceil(end / model.FPS + 12 / state.zoom) * model.FPS : 0; + const r = node.getBoundingClientRect(), left = content.getBoundingClientRect().left + pixels(start), width = 3 * state.zoom; + return {node, rect: {left, right: left + width, top: r.top, bottom: r.bottom, width, height: r.height}, reveal: reveal(left, width)}; + } + return {element, keyboard, menu, dismissPopup, flashBlocked, seekPlayhead, extendClip, guideTarget, setSnapping(enabled) { if (typeof enabled !== 'boolean') return {ok: false, reason: 'snapping'}; setTransportSnapping(state, enabled); cachedPoints = allPoints(gesture?.ids || []); diff --git a/web/first_run_guide.css b/web/first_run_guide.css index 85b7fcba..2a92a3eb 100644 --- a/web/first_run_guide.css +++ b/web/first_run_guide.css @@ -1,6 +1,7 @@ .fv-studio .fv-guide-layer{position:fixed;inset:0;z-index:40;pointer-events:none;overflow:hidden;font-family:var(--fv-font);color:var(--fv-ink)} .fv-studio .fv-guide-ring{position:absolute;border:2px solid var(--fv-accent);box-shadow:0 0 0 9999px rgb(0 0 0 / 42%);pointer-events:none} .fv-studio .fv-guide-bubble{position:absolute;pointer-events:auto;width:340px;max-width:calc(100vw - 48px);padding:20px;border-radius:14px;background:var(--fv-raised);border:1px solid var(--fv-border);box-shadow:0 16px 48px #0005} +.fv-studio .fv-guide-layer[data-group=editor] .fv-guide-bubble{max-width:calc(100vw - 24px)} .fv-studio .fv-guide-message{font-size:14px;font-weight:400;line-height:1.6;text-wrap:balance;margin:0;white-space:normal;overflow-wrap:normal;color:var(--fv-ink)} .fv-studio .fv-guide-footer{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px} .fv-studio .fv-guide-dots{display:flex;align-items:center;gap:6px;flex-shrink:0} diff --git a/web/first_run_guide.js b/web/first_run_guide.js index 41d452e9..3b6f5f16 100644 --- a/web/first_run_guide.js +++ b/web/first_run_guide.js @@ -1,5 +1,8 @@ const FILE = '/userdata/' + encodeURIComponent('freevideo/guide.json'); -let attempted = false, current = null; +const attempted = new Set(); +let current = null; +const guides = new Map(); +const recordWrites = new WeakMap(); export const REPLAY_TEXT = { title: ['Getting started', '新手指引'], @@ -21,11 +24,22 @@ export const CHAPTERS = [ en: 'For a longer video, or to change part of one, choose "Edit".', zh: '要做更长的视频,或者修\u2060改\u2060其\u2060中\u2060一\u2060段,点“剪辑视图”。'}, {id: 'agent', anchor: '[data-fv-guide="agent"]', added: true, en: 'To have an AI agent such as Claude Code or Codex make videos for you, choose "Autopilot".', zh: '要让 Claude Code、Codex 等 Agent 替你生\u2060成\u2060视\u2060频,点“AI 全自动”。'}, + {id: 'editor-timeline', group: 'editor', anchor: '[data-fv-guide="editor-timeline"]', added: true, + en: 'Drag a video here.', zh: '把视频拖到这里。'}, + {id: 'editor-trim', group: 'editor', anchor: '[data-fv-guide="editor-trim"]', added: true, + en: 'Drag the right edge to make this part longer.', zh: '拖右边缘,把这一段拉长。'}, + {id: 'editor-empty', group: 'editor', anchor: '[data-fv-guide="editor-empty"]', added: true, + en: 'Drag in an empty spot to add a new part.', zh: '在空白处拖出新的一段。'}, + {id: 'editor-describe', group: 'editor', anchor: '[data-fv-guide="editor-describe"]', + anchors: ['[data-fv-guide="editor-describe"]', '[data-fv-guide="editor-generate"]'], added: true, + en: 'Describe it, then choose "Generate".', zh: '写一句描述,再点“\u2060生\u2060成\u2060”\u2060。'}, ]; -export function guideSteps({record, available = [], returning = false, blockers = {}, all = false}) { +const chaptersFor = group => CHAPTERS.filter(c => (c.group || 'studio') === group); + +export function guideSteps({record, available = [], returning = false, blockers = {}, all = false, group = 'studio'}) { if (Object.values(blockers).some(Boolean)) return []; - return CHAPTERS.filter(chapter => available.includes(chapter.id) + return chaptersFor(group).filter(chapter => available.includes(chapter.id) && (all || (!record?.seen.includes(chapter.id) && (record || !returning || chapter.added)))); } @@ -35,6 +49,9 @@ export function guideRecord(record, ids) { export async function readGuideRecord(api) { try { + // Switching workspaces can race a slow acknowledgement from the old + // guide. Read after its writes so the next group retains those ids. + await recordWrites.get(api); const reply = await api.fetchApi(FILE, {cache: 'no-store'}); if (reply.status === 404) return {ok: true, record: null}; if (!reply.ok) return {ok: false}; @@ -44,15 +61,19 @@ export async function readGuideRecord(api) { } catch { return {ok: false}; } } -export async function saveGuideRecord(api, record) { - try { - return (await api.fetchApi(FILE + '?overwrite=true', {method: 'POST', body: JSON.stringify(record), keepalive: true})).ok; - } catch { return false; } +export function saveGuideRecord(api, record) { + const pending = (recordWrites.get(api) || Promise.resolve()).then(async () => { + try { + return (await api.fetchApi(FILE + '?overwrite=true', {method: 'POST', body: JSON.stringify(record), keepalive: true})).ok; + } catch { return false; } + }); + recordWrites.set(api, pending); + return pending; } // An empty record checks write access before drawing anything. Never infer a // new installation from an unreadable library, or consume an absent chapter. -export async function prepareGuide(api, available) { +export async function prepareGuide(api, available, group = 'studio') { const loaded = await readGuideRecord(api); if (!loaded.ok) return null; let record = loaded.record, returning = false; @@ -65,8 +86,8 @@ export async function prepareGuide(api, available) { returning = data.items.length > 0; } catch { return null; } } - const steps = guideSteps({record, available, returning}); - record = guideRecord(record, returning ? CHAPTERS.filter(c => !c.added && available.includes(c.id)).map(c => c.id) : []); + const steps = guideSteps({record, available, returning, group}); + record = guideRecord(record, returning ? chaptersFor(group).filter(c => !c.added && available.includes(c.id)).map(c => c.id) : []); if (!steps.length) { if (returning) await saveGuideRecord(api, record); return null; @@ -76,6 +97,7 @@ export async function prepareGuide(api, available) { } export function showGuide({all = true} = {}) { + current = [...document.querySelectorAll('dialog[open]')].map(dialog => guides.get(dialog)).filter(Boolean).at(-1) || current; return (all ? current?.replay() : current?.show()) || Promise.resolve(false); } @@ -112,9 +134,22 @@ export function createGuideReplayEntry({dialog, cn, openWorkspace, replay = show return section; } -export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { +// Custom targets return viewport rectangles on every layout pass. They never +// insert hit targets or change the editor's pointer handling. +export function guideTarget(dialog, chapter, targets = {}) { + if (targets[chapter.id]) return targets[chapter.id](); + const nodes = (chapter.anchors || [chapter.anchor]).map(selector => dialog.querySelector(selector)); + if (nodes.some(node => !visible(node))) return null; + const rects = nodes.map(node => node.getBoundingClientRect()); + const left = Math.min(...rects.map(r => r.left)), top = Math.min(...rects.map(r => r.top)); + const right = Math.max(...rects.map(r => r.right)), bottom = Math.max(...rects.map(r => r.bottom)); + return {node: nodes[0], rect: {left, top, right, bottom, width: right - left, height: bottom - top}}; +} + +export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, group = 'studio', targets = {}}) { const t = (en, zh) => cn ? zh : en; - const automatic = !attempted; + const automatic = !attempted.has(group), editor = group === 'editor'; + const chapters = chaptersFor(group); if (!document.querySelector('link[data-fv-first-run-guide]')) { const css = el('link'); css.rel = 'stylesheet'; css.dataset.fvFirstRunGuide = ''; css.href = new URL('./first_run_guide.css', import.meta.url).href; document.head.append(css); @@ -125,10 +160,17 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { let waiting = null, replayTimer = null; const heldKeys = new Set(); const target = chapter => dialog.querySelector(chapter.anchor); - const available = () => CHAPTERS.filter(c => visible(target(c))).map(c => c.id); + const resolved = chapter => { + const value = guideTarget(dialog, chapter, targets); + return value && visible(value.node) && value.rect.width > 0 && value.rect.height > 0 ? value : null; + }; + const available = () => chapters.filter(c => resolved(c)).map(c => c.id); function blocked() { - return !dialog.open || serverBlocked || isBusy() - || [...document.querySelectorAll('dialog[open]')].some(e => e !== dialog) + return !dialog.open || !dialog.isConnected || dialog.inert || serverBlocked || isBusy() + // Create stays open underneath Edit. It is an inactive workspace, + // while settings, export options and other dialogs still block. + || [...document.querySelectorAll('dialog[open]')].some(e => e !== dialog + && !(editor && e.matches('dialog.fv-studio:not(.fv-editor)'))) || [...document.querySelectorAll('[role="dialog"], [aria-modal="true"], .p-dialog-mask, .p-overlay-mask:not(.p-blockui-mask)')] .some(e => e !== dialog && !layer?.contains(e) && visible(e)) || [...document.querySelectorAll('.fv-failure, .fv-health, .fv-update-notice, .fv-prewarm, [data-fv-guide-blocker]')] @@ -149,10 +191,12 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { layer?.remove(); layer = bubble = ring = arrow = null; if (restore && focused && restoreFocus?.isConnected) restoreFocus.focus({preventScroll: true}); } - function interrupt() { if (automatic && dialog.open) attempted = true; stopped = true; clearTimeout(pollTimer); hide(); } + function interrupt() { if (automatic && dialog.open) attempted.add(group); stopped = true; clearTimeout(pollTimer); hide(); } function remember() { if (manual) return; - record = guideRecord(record, steps.filter(c => visible(target(c))).map(c => c.id)); + // Empty projects acknowledge the optional trim chapter too, so importing + // later cannot turn a completed three-step guide into a second prompt. + record = guideRecord(record, editor ? chapters.map(c => c.id) : steps.filter(c => resolved(c)).map(c => c.id)); // A failed write stays quiet. This page's single attempt is already used. saveGuideRecord(api, record).then(ok => { if (!ok && !disposed) interrupt(); }); } @@ -160,22 +204,30 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { function position() { if (!layer) return; if (blocked()) { interrupt(); return; } - const node = target(steps[index]); - if (!visible(node)) { + let value = resolved(steps[index]); + if (value?.reveal) { value.reveal(); value = resolved(steps[index]); } + if (!value) { steps.splice(index, 1); if (!steps.length) interrupt(); else { index = Math.min(index, steps.length - 1); draw(); } return; } - const r = node.getBoundingClientRect(), bounds = dialog.getBoundingClientRect(); - const xmin = Math.max(24, bounds.left + 12), ymin = Math.max(24, bounds.top + 12); - const xmax = Math.min(innerWidth - 24, bounds.right - 12), ymax = Math.min(innerHeight - 24, bounds.bottom - 12); + const {node, rect: raw} = value, bounds = dialog.getBoundingClientRect(); + const margin = editor ? 12 : 24; + const xmin = Math.max(margin, bounds.left + 12), ymin = Math.max(margin, bounds.top + 12); + const xmax = Math.min(innerWidth - margin, bounds.right - 12), ymax = Math.min(innerHeight - margin, bounds.bottom - 12); + const r = editor ? {left: Math.max(0, raw.left), top: Math.max(0, raw.top), + right: Math.min(innerWidth, raw.right), bottom: Math.min(innerHeight, raw.bottom)} : raw; + r.width = r.right - r.left; r.height = r.bottom - r.top; + if (r.width <= 0 || r.height <= 0) { interrupt(); return; } bubble.style.width = `${Math.min(340, xmax - xmin)}px`; const b = bubble.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2; const x = clamp(cx - b.width / 2, xmin, xmax - b.width), y = clamp(cy - b.height / 2, ymin, ymax - b.height); const below = {side: 'below', x, y: r.bottom + 24}, above = {side: 'above', x, y: r.top - b.height - 24}; const narrow = innerWidth <= 680; - const candidates = steps[index].id === 'longer' + const candidates = editor + ? [{...above, x: steps[index].id === 'editor-describe' ? clamp(r.left, xmin, xmax - b.width) : x}] + : steps[index].id === 'longer' ? [{side: 'below', x: clamp(r.right - b.width, xmin, xmax - b.width), y: r.bottom + 24}, above, below] : narrow ? [{side: 'below', x, y: ymax - b.height}, above, below] : [{side: 'right', x: r.right + 24, y}, {side: 'left', x: r.left - b.width - 24, y}, below, above]; @@ -184,8 +236,9 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { const place = candidates.find(fits); if (!place) { interrupt(); return; } const style = getComputedStyle(node); - Object.assign(ring.style, {left: `${r.left - 4}px`, top: `${r.top - 4}px`, width: `${r.width + 8}px`, height: `${r.height + 8}px`, - borderRadius: ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'].map(n => `${(parseFloat(style[n]) || 0) + 4}px`).join(' ')}); + const pad = editor ? 0 : 4; + Object.assign(ring.style, {left: `${r.left - pad}px`, top: `${r.top - pad}px`, width: `${r.width + 2 * pad}px`, height: `${r.height + 2 * pad}px`, + borderRadius: ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'].map(n => `${(parseFloat(style[n]) || 0) + pad}px`).join(' ')}); Object.assign(bubble.style, {left: `${place.x}px`, top: `${place.y}px`}); bubble.dataset.side = place.side; arrow.style.left = arrow.style.top = ''; if (['left', 'right'].includes(place.side)) arrow.style.top = `${clamp(cy - place.y - 6, 20, b.height - 32)}px`; @@ -194,13 +247,14 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { const follow = () => { if (layer && frame === null) frame = requestAnimationFrame(() => { frame = null; position(); }); }; function draw() { hide(false); - while (steps[index] && !visible(target(steps[index]))) steps.splice(index, 1); + resolved(steps[index])?.reveal?.(); + while (steps[index] && !resolved(steps[index])) steps.splice(index, 1); if (!steps[index]) { interrupt(); return; } - const node = target(steps[index]), r = node.getBoundingClientRect(), bounds = dialog.getBoundingClientRect(); + const {node, rect: r} = resolved(steps[index]), bounds = dialog.getBoundingClientRect(); const clip = node.closest('.fv-controls, .fv-preview-column')?.getBoundingClientRect() || bounds; - if (innerWidth <= 680 || r.top < clip.top + 8 || r.bottom > clip.bottom - 8) + if (!editor && (innerWidth <= 680 || r.top < clip.top + 8 || r.bottom > clip.bottom - 8)) node.scrollIntoView({block: 'center', inline: 'nearest', behavior: 'instant'}); - layer = el('div', null, 'fv-guide-layer'); layer.dataset.chapter = steps[index].id; + layer = el('div', null, 'fv-guide-layer'); layer.dataset.chapter = steps[index].id; layer.dataset.group = group; ring = el('div', null, 'fv-guide-ring'); ring.setAttribute('aria-hidden', 'true'); bubble = el('div', null, 'fv-guide-bubble'); bubble.setAttribute('role', 'dialog'); bubble.setAttribute('aria-modal', 'false'); bubble.setAttribute('aria-label', t('Getting started', '使用指引')); bubble.setAttribute('aria-describedby', 'fv-guide-message'); @@ -228,16 +282,17 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { } async function show(all = false, request = null) { const cancelled = () => request && waiting !== request; - if (all) serverBlocked = false; + if (all || editor) serverBlocked = false; if (disposed || preparing || layer || !dialog.open || blocked() || (!all && (touched || stopped))) return false; preparing = true; manual = all; await document.fonts.ready; if (cancelled() || [...document.querySelectorAll('.p-blockui-mask')].some(visible)) { preparing = false; return false; } const ready = await serverState(); + if (editor) serverBlocked = !ready; let prepared = null; if (ready && !disposed && !cancelled() && !blocked() && (all || !touched)) { - if (all) prepared = {record, steps: guideSteps({available: available(), all: true})}; - else prepared = await prepareGuide(api, available()); + if (all) prepared = {record, steps: guideSteps({available: available(), all: true, group})}; + else prepared = await prepareGuide(api, available(), group); } preparing = false; if (!prepared?.steps.length || disposed || cancelled() || blocked() || (!all && (touched || stopped))) return false; @@ -257,7 +312,7 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { } async function replay() { if (disposed || !dialog.open) return false; - attempted = true; manual = true; clearTimeout(startTimer); interrupt(); cancelReplay(); + attempted.add(group); manual = true; clearTimeout(startTimer); interrupt(); cancelReplay(); waiting = el('div', null, 'fv-guide-wait'); const message = el('p', t(...REPLAY_TEXT.waiting)); message.setAttribute('role', 'status'); const cancel = el('button', t(...REPLAY_TEXT.cancel)); cancel.type = 'button'; cancel.onclick = cancelReplay; @@ -267,7 +322,7 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { } const touch = event => { if (!dialog.contains(event.target) || layer?.contains(event.target)) return; - if (automatic) attempted = true; + if (automatic) attempted.add(group); touched = true; if (layer) finish(); else stopped = true; }; @@ -292,42 +347,66 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false}) { if (event.type === 'keyup') heldKeys.delete(event.key); }; const status = event => { - if (event.detail?.exec_info?.queue_remaining > 0) { serverBlocked = true; interrupt(); } + if (event.detail?.exec_info?.queue_remaining > 0) { serverBlocked = true; if (!editor || layer) interrupt(); } else if (layer) poll(); }; - const execution = () => { serverBlocked = true; interrupt(); }; + const execution = () => { serverBlocked = true; if (!editor || layer) interrupt(); }; const observer = new MutationObserver(rows => { if (disposed || stopped) return; if (rows.every(row => layer?.contains(row.target))) return; - if (blocked() || layer && [...document.querySelectorAll('.p-blockui-mask')].some(visible)) interrupt(); else follow(); + if ((!editor || layer) && (blocked() || layer && [...document.querySelectorAll('.p-blockui-mask')].some(visible))) interrupt(); else follow(); }); observer.observe(document.body, {subtree: true, childList: true, attributes: true, attributeFilter: ['hidden', 'open', 'class', 'style', 'inert', 'role', 'aria-modal']}); const resizeObserver = new ResizeObserver(follow); document.addEventListener('pointerdown', touch, true); document.addEventListener('click', touch, true); + document.addEventListener('dragstart', touch, true); document.addEventListener('dragenter', touch, true); document.addEventListener('input', touch, true); document.addEventListener('keydown', key, true); document.addEventListener('keypress', keyEnd, true); document.addEventListener('keyup', keyEnd, true); window.addEventListener('resize', follow); document.addEventListener('scroll', follow, true); - api.addEventListener('status', status); api.addEventListener('execution_start', execution); - const handle = {show, replay, dispose() { + // The editor can be opened with a bare request helper that has no events. + const events = api?.addEventListener && api.removeEventListener; + if (events) { api.addEventListener('status', status); api.addEventListener('execution_start', execution); } + const handle = {show, replay, suspend() { cancelReplay(); interrupt(); }, + handleKey(event) { + if (!['keydown', 'keypress', 'keyup'].includes(event.type)) return false; + const handled = !!layer || heldKeys.has(event.key); + if (event.type === 'keydown') key(event); else keyEnd(event); + return handled; + }, dispose() { disposed = true; clearTimeout(startTimer); clearTimeout(pollTimer); cancelAnimationFrame(frame); cancelReplay(); hide(); observer.disconnect(); resizeObserver.disconnect(); document.removeEventListener('pointerdown', touch, true); document.removeEventListener('click', touch, true); + document.removeEventListener('dragstart', touch, true); document.removeEventListener('dragenter', touch, true); document.removeEventListener('input', touch, true); document.removeEventListener('keydown', key, true); document.removeEventListener('keypress', keyEnd, true); document.removeEventListener('keyup', keyEnd, true); window.removeEventListener('resize', follow); document.removeEventListener('scroll', follow, true); - api.removeEventListener('status', status); api.removeEventListener('execution_start', execution); - if (current === handle) current = null; + if (events) { api.removeEventListener('status', status); api.removeEventListener('execution_start', execution); } + guides.delete(dialog); + if (current === handle) current = [...guides.values()].at(-1) || null; }}; - current = handle; + guides.set(dialog, handle); + const foreground = [...document.querySelectorAll('dialog[open]')].at(-1); + if (!foreground || foreground === dialog) { current?.suspend(); current = handle; } // Steps are fixed when the guide starts, so give an anchor that is still waiting for its data up to 5 s. // The Agent tab is not waited for: its discovery has already finished when the guide is created. - const pending = () => CHAPTERS.some(c => c.id !== 'agent' && target(c) && !visible(target(c))); + const pending = () => chapters.some(c => c.id !== 'agent' && target(c) && !visible(target(c))); function begin(waited = 0) { startTimer = setTimeout(() => { - if (!automatic || attempted || manual || layer || disposed) return; + if (!automatic || attempted.has(group) || manual || layer || disposed || touched) return; + if (editor) { + if (!dialog.open || !dialog.isConnected) return; + serverBlocked = false; + if (blocked() || pending()) { begin(250); return; } + show().then(shown => { + if (disposed || touched) return; + if (!shown && (serverBlocked || blocked())) begin(250); + else { attempted.add(group); if (!shown) stopped = true; } + }); + return; + } if (pending() && waited < 5000) { begin(waited + 250); return; } - attempted = true; + attempted.add(group); show().then(shown => { if (!shown) stopped = true; }); }, waited ? 250 : 1000); } diff --git a/web/view_navigation.js b/web/view_navigation.js index c2359147..cf145e5b 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -71,6 +71,8 @@ export function refreshNavigation() { } export function openCreativeWorkspace() { + // Settings replay belongs to the visible editing workspace too. + if (currentView === 'editor' && document.querySelector('dialog.fv-editor[open]')) return true; refreshNavigation(); const focused = Object.values(app.canvas?.selected_nodes || {}).find(n => n.comfyClass === 'FreeVideoGenerate' || n.type === 'FreeVideoGenerate'); const node = currentView === 'studio' ? selected : focused || selected; From e5fc6d613e4b82f77f2e08d90aa20136c1aab5df Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 08:21:07 +0000 Subject: [PATCH 09/53] LoRA trigger words in creation, Agent and chain prompts --- freevideo_engine/agent/jobs.py | 1 + freevideo_engine/agent/routes.py | 2 + freevideo_engine/agent/spec.py | 5 +- freevideo_engine/chain_runner.py | 2 + freevideo_engine/comfy_chain.py | 3 +- freevideo_engine/comfy_upload.py | 23 +++ freevideo_engine/lora_triggers.py | 226 ++++++++++++++++++++++++++++++ web/health.js | 2 +- web/lora_triggers.js | 45 ++++++ web/studio.css | 2 + web/studio.js | 163 +++++++++++++++++++-- 11 files changed, 457 insertions(+), 17 deletions(-) create mode 100644 freevideo_engine/lora_triggers.py create mode 100644 web/lora_triggers.js diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 47eff87c..4384f12e 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -1434,6 +1434,7 @@ def resolve(): return assets assets = resolve() graph = specs.comfy_prompt(spec, node, prompt=prompt, assets=assets) + prompt = graph[node]['inputs']['text'] with self.lock: row['inputs'] = dict(media=assets, prompt_chars=len(prompt)) if row.get('_cancel'): diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index e7dd0902..00346783 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -171,6 +171,7 @@ def features(continuation, vlm): def capabilities(port, jobs): + from ..lora_triggers import list_triggers from .. import __version__ from ..desktop_runtime import source_release from ..comfy_assets import EXTENSIONS @@ -217,6 +218,7 @@ def capabilities(port, jobs): aspects=sorted(specs.ASPECTS), native_megapixels=.98, tasks=GUIDE['tasks'], media_extensions={k: sorted(v) for k, v in EXTENSIONS.items()}, loras=sorted(n for n in port.folders.get_filename_list('loras') if n.lower().endswith('.safetensors')), + lora_trigger_words=list_triggers(port.folders), vlm=vlm, queue=dict(running=len(running), pending=len(pending), agent_active=len(jobs.active())), docs=dict(guide=PREFIX + '/guide', schema=PREFIX + '/schema', openapi=PREFIX + '/openapi.json')) diff --git a/freevideo_engine/agent/spec.py b/freevideo_engine/agent/spec.py index d44e60de..96ab196a 100644 --- a/freevideo_engine/agent/spec.py +++ b/freevideo_engine/agent/spec.py @@ -243,6 +243,8 @@ def normalize(value, *, port=None): def comfy_prompt(spec, node, *, prompt, assets, refine_from=''): """The ComfyUI API-format graph for one generate job; media are files in ComfyUI input.""" + from ..lora_triggers import apply_triggers + prompt = apply_triggers(prompt, spec.get('loras', [])) inputs = dict(text=prompt, width=spec['width'], height=spec['height'], seconds=spec['seconds'], seed=spec['seed'], two_pass=spec['two_pass'], base_steps=spec['base_steps'], refine_steps=spec['refine_steps'], force_regenerate=spec['force_regenerate'], @@ -258,7 +260,8 @@ def comfy_prompt(spec, node, *, prompt, assets, refine_from=''): inputs['media'] = [node + 'm', 0] if spec.get('loras'): graph[node + 'l'] = {'class_type': 'FreeVideoLoRAStack', - 'inputs': {'adapters': json.dumps([dict(r, enabled=True) for r in spec['loras']])}, + 'inputs': {'adapters': json.dumps([dict(r, enabled=True) for r in spec['loras'] + if r.get('enabled', True)])}, '_meta': {'title': 'FreeVideo · LoRAs'}} inputs['loras'] = [node + 'l', 0] return graph diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index fbbb0b19..f48c478b 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -183,7 +183,9 @@ def _work_parts(self, work, seen): return 1 def create(self, payload): + from .lora_triggers import apply_chain_triggers record = store.validate(payload) + apply_chain_triggers(record) with self.lock: # Before anything is written or queued: a refused chain leaves every record and file as it was. short = [row['inputs']['frames'] for row in record['blocks'] diff --git a/freevideo_engine/comfy_chain.py b/freevideo_engine/comfy_chain.py index c770c383..18ceae9c 100644 --- a/freevideo_engine/comfy_chain.py +++ b/freevideo_engine/comfy_chain.py @@ -1,4 +1,5 @@ """Local ComfyUI queue adapter and optional background-chain routes.""" +import asyncio import json import logging import math @@ -271,7 +272,7 @@ async def create(request): return response({'error': 'Continuing a video is not available in this version'}, 409) if missing: return response({'error': 'Extending backwards or filling a gap is not available in this version'}, 409) - return response({'chain': runner.create(value)}) + return response({'chain': await asyncio.to_thread(runner.create, value)}) except ChainBusy as failure: return response({'error': str(failure)}, 409) except ChainTooLong as failure: diff --git a/freevideo_engine/comfy_upload.py b/freevideo_engine/comfy_upload.py index e8edcaaf..36feabca 100644 --- a/freevideo_engine/comfy_upload.py +++ b/freevideo_engine/comfy_upload.py @@ -1,4 +1,5 @@ """Stream browser media to ComfyUI input without buffering a clip in RAM.""" +import asyncio from pathlib import Path, PureWindowsPath import uuid @@ -57,4 +58,26 @@ async def upload(request): return web.json_response({'error': str(error)}, status=400) return web.json_response(result) + from .lora_triggers import get_trigger, set_trigger + + @server.routes.get('/freevideo/lora/triggers') + async def lora_triggers(request): + try: + result = await asyncio.to_thread(get_trigger, request.query.get('name'), folders=folder_paths) + except (OSError, ValueError) as error: + return web.json_response({'error': str(error)}, status=400) + return web.json_response(result, headers={'Cache-Control': 'no-store'}) + + @server.routes.post('/freevideo/lora/triggers') + async def save_lora_triggers(request): + try: + value = await request.json() + if not isinstance(value, dict): + raise ValueError('A JSON object is required') + result = await asyncio.to_thread(set_trigger, value.get('name'), value.get('words'), + folders=folder_paths) + except (OSError, ValueError, TypeError) as error: + return web.json_response({'error': str(error)}, status=400) + return web.json_response(result, headers={'Cache-Control': 'no-store'}) + server._freevideo_upload_registered = True diff --git a/freevideo_engine/lora_triggers.py b/freevideo_engine/lora_triggers.py new file mode 100644 index 00000000..c29e4e44 --- /dev/null +++ b/freevideo_engine/lora_triggers.py @@ -0,0 +1,226 @@ +"""User-entered LoRA triggers, identified by content rather than library location. + +All functions here perform synchronous file I/O. HTTP callers use to_thread; +Agent graph construction and chain creation already run on worker threads. +""" +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import threading + +from .comfy_assets import resolve_loras +from .launcher_settings import Store +from .long_paths import io_path +from .model_repair import valid_name +from .paths import data_root + +MAX_WORDS = 300 +CHUNK_BYTES = 1024 * 1024 +TRIGGERS_FILE = 'lora-triggers.json' +CACHE_FILE = 'lora-fingerprints.json' +_SHA256 = re.compile(r'[0-9a-f]{64}') +_LOCK = threading.RLock() + + +def settings_directory(): + # Alongside durable launcher preferences, outside model and runtime folders. + return Store(data_root()).primary.parent + + +def _read(path): + try: + def invalid_number(value): + raise ValueError('Invalid JSON number') + value = json.loads(io_path(path).read_text(encoding='utf-8'), parse_constant=invalid_number) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} + + +def _write(path, value): + path = io_path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent, + prefix=path.name + '.', suffix='.tmp', delete=False) as stream: + temporary = Path(stream.name) + json.dump(value, stream, ensure_ascii=False, allow_nan=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _valid_name(name): + return (valid_name(name) and '..' not in name + and not any(c in name for c in (':', '\x00', '\r', '\n')) + and name.lower().endswith('.safetensors')) + + +def resolve_name(name, folders=None): + if not _valid_name(name): + raise ValueError('Select an existing LoRA filename without path separators or ..') + if folders is None: + import folder_paths as folders + # Reuse the stack's library membership and safetensors validation. + rows = resolve_loras(json.dumps([dict(name=name)]), folders.get_filename_list('loras'), + lambda n: folders.get_full_path_or_raise('loras', n)) + path = io_path(rows[0]['path']) + if not path.is_file(): + raise ValueError('Select an existing LoRA file') + return path + + +def _hash_file(path): + digest = hashlib.sha256() + with path.open('rb') as stream: + while chunk := stream.read(CHUNK_BYTES): + digest.update(chunk) + return digest.hexdigest() + + +def fingerprint(path, directory=None): + """Full SHA-256; persistent cache identity is resolved path, size and mtime_ns.""" + path = io_path(Path(path).resolve()) + directory = settings_directory() if directory is None else Path(directory) + with _LOCK: + info = path.stat() + cache = _read(directory / CACHE_FILE).get('files', {}) + cache = cache if isinstance(cache, dict) else {} + key = str(path) + row = cache.get(key) + if (isinstance(row, dict) and type(row.get('size')) is int + and type(row.get('mtime_ns')) is int + and (row['size'], row['mtime_ns']) == (info.st_size, info.st_mtime_ns) + and isinstance(row.get('sha256'), str) and _SHA256.fullmatch(row['sha256'])): + return row['sha256'] + digest = _hash_file(path) + after = path.stat() + if (info.st_size, info.st_mtime_ns) != (after.st_size, after.st_mtime_ns): + raise ValueError('LoRA changed while reading; try again') + cache[key] = dict(size=info.st_size, mtime_ns=info.st_mtime_ns, sha256=digest) + _write(directory / CACHE_FILE, dict(version=1, files=cache)) + return digest + + +def _entries(directory): + rows = _read(directory / TRIGGERS_FILE).get('triggers', {}) + if not isinstance(rows, dict): + return {} + return {key: dict(words=row['words'].strip(), source='user', name=row.get('name', '')) + for key, row in rows.items() + if isinstance(key, str) and _SHA256.fullmatch(key) and isinstance(row, dict) + and isinstance(row.get('words'), str) and 0 < len(row['words'].strip()) <= MAX_WORDS + and row.get('source') == 'user' and isinstance(row.get('name', ''), str)} + + +def get_trigger(name, *, folders=None): + with _LOCK: + path = resolve_name(name, folders) + directory = settings_directory() + digest = fingerprint(path, directory) + rows = _entries(directory) + row = rows.get(digest) + if row is not None and row['name'] != name: + row['name'] = name + _write(directory / TRIGGERS_FILE, dict(version=1, triggers=rows)) + return dict(name=name, words=row['words'] if row else '', source='user' if row else '') + + +def set_trigger(name, words, *, folders=None): + if not isinstance(words, str): + raise ValueError('Trigger words must be a string') + words = words.strip() + if len(words) > MAX_WORDS: + raise ValueError('Trigger words must be at most 300 characters') + with _LOCK: + path = resolve_name(name, folders) + directory = settings_directory() + digest = fingerprint(path, directory) + rows = _entries(directory) + if words: + rows[digest] = dict(name=name, words=words, source='user') + else: + rows.pop(digest, None) + _write(directory / TRIGGERS_FILE, dict(version=1, triggers=rows)) + return dict(name=name, words=words, source='user' if words else '') + + +def get_trigger_words(name, *, folders=None): + # No saved triggers means no model-file reads when listing or submitting LoRAs. + # Subfolder names admitted by existing workflows cannot have an API entry. + with _LOCK: + if not _valid_name(name) or not _entries(settings_directory()): + return '' + return get_trigger(name, folders=folders)['words'] + + +def prepend_words(prompt, words): + if not words or words.casefold() in prompt.casefold(): + return prompt + return words + ', ' + prompt if prompt else words + + +def apply_triggers(prompt, loras, *, folders=None): + for row in loras: + if row.get('enabled', True) and row.get('strength', 1.) != 0: + prompt = prepend_words(prompt, get_trigger_words(row['name'], folders=folders)) + return prompt + + +def list_triggers(folders): + """Installed LoRA names that have trigger words saved, as {name: words}.""" + found = {} + for name in sorted(folders.get_filename_list('loras')): + if name.lower().endswith('.safetensors'): + words = get_trigger_words(name, folders=folders) + if words: + found[name] = words + return found + + +def _stack_rows(graph, link, seen=None): + """Follow only the selected generator's LoRA input and chained previous stacks.""" + seen = set() if seen is None else seen + if (not isinstance(link, list) or len(link) != 2 or type(link[0]) not in (str, int) + or link[0] in seen or link[0] not in graph): + return [] + seen.add(link[0]) + node = graph[link[0]] + if not isinstance(node, dict) or not isinstance(node.get('inputs'), dict): + return [] + inputs = node['inputs'] + rows = _stack_rows(graph, inputs.get('previous'), seen) + if node.get('class_type') == 'FreeVideoLoRAStack': + serialized = inputs.get('adapters', '[]') + if not isinstance(serialized, str) or len(serialized) > 128 * 1024: + raise ValueError('Invalid LoRA selection') + selected = json.loads(serialized) + if not isinstance(selected, list) or any(not isinstance(row, dict) for row in selected): + raise ValueError('Invalid LoRA selection') + rows.extend(selected) + elif node.get('class_type') == 'FreeVideoLoRA' and inputs.get('lora') != 'None': + rows.append(dict(name=inputs.get('lora'), strength=inputs.get('strength', 1.))) + return rows + + +def apply_chain_triggers(record, *, folders=None): + """Supplement a validated chain before its snapshot and block texts are saved.""" + graph = record['snapshot']['prompt'] + inputs = graph[record['node']]['inputs'] + rows = _stack_rows(graph, inputs.get('loras')) + # Resolve once per chain, then supplement each independently supplied block. + words = [get_trigger_words(row.get('name'), folders=folders) for row in rows + if row.get('enabled', True) and row.get('strength', 1.) != 0] + for item in [inputs, *(block['inputs'] for block in record['blocks'])]: + if isinstance(item.get('text'), str): + for phrase in words: + item['text'] = prepend_words(item['text'], phrase) + if any(len(block['inputs']['text']) > 20000 for block in record['blocks']): + raise ValueError('Block text including LoRA triggers must be at most 20000 characters') diff --git a/web/health.js b/web/health.js index 536d6e4d..03987742 100644 --- a/web/health.js +++ b/web/health.js @@ -15,7 +15,7 @@ const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', - 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', + 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'report_issue.js', 'studio_recent.js', 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js']; diff --git a/web/lora_triggers.js b/web/lora_triggers.js new file mode 100644 index 00000000..663350f8 --- /dev/null +++ b/web/lora_triggers.js @@ -0,0 +1,45 @@ +// Records are saved on the generation node: {name, words, added}. A phrase +// already written by the user has added=false, so it is never removed or +// reinserted after a manual edit. A missing table entry means lookup is pending. +function removeInsertion(prompt, words) { + const index = prompt.indexOf(words); + if (index < 0) return prompt; + const before = prompt.slice(0, index), after = prompt.slice(index + words.length); + const following = after.match(/^\s*,\s*/); + if (following) return before + after.slice(following[0].length); + const preceding = before.match(/,\s*$/); + return (preceding ? before.slice(0, -preceding[0].length) : before) + after; +} + +export function reconcileLoraTriggers(prompt, records, rows, wordsByName) { + prompt = String(prompt ?? ''); + const names = [...new Set(rows.filter(row => row.enabled !== false && row.name).map(row => row.name))]; + const active = new Set(names); + const previous = new Map((Array.isArray(records) ? records : []) + .filter(row => row && typeof row.name === 'string' && typeof row.words === 'string' && row.words) + .map(row => [row.name, {...row}])); + const words = name => Object.hasOwn(wordsByName, name) ? String(wordsByName[name] ?? '').trim() : undefined; + const retained = new Map([...previous].filter(([name, row]) => active.has(name) + && (words(name) === undefined || words(name) === row.words))); + const needed = new Set(names.map(name => words(name) ?? retained.get(name)?.words).filter(Boolean)); + const removed = new Set(); + for (const old of previous.values()) { + if (old.added && !retained.has(old.name) && !needed.has(old.words) && !removed.has(old.words)) { + prompt = removeInsertion(prompt, old.words); removed.add(old.words); + } + } + // Shared phrases have joint ownership, including when one model replaces + // another with the same phrase. Remove them only after the last owner leaves. + const owned = new Set([...previous.values()].filter(row => row.added && prompt.includes(row.words)).map(row => row.words)); + const next = [], prefix = []; + for (const name of names) { + if (retained.has(name)) { next.push(retained.get(name)); continue; } + const phrase = words(name); + if (!phrase) continue; + const present = [...prefix, prompt].join(', ').toLowerCase().includes(phrase.toLowerCase()); + const added = !present || owned.has(phrase); + if (!present) { prefix.push(phrase); owned.add(phrase); } + next.push({name, words: phrase, added}); + } + return {prompt: [...prefix, ...(prompt ? [prompt] : [])].join(', '), records: next}; +} diff --git a/web/studio.css b/web/studio.css index b20eb021..f5992760 100644 --- a/web/studio.css +++ b/web/studio.css @@ -127,6 +127,8 @@ .fv-lora-list{display:flex;flex-direction:column;gap:8px;margin:12px 0}.fv-lora-row{display:grid;grid-template-columns:18px minmax(0,1fr) 64px 30px;align-items:center;gap:7px} .fv-lora-row select{min-width:0!important;max-width:100%!important;width:100%!important}.fv-lora-row input[type=number]{width:64px!important;padding:6px!important;font-variant-numeric:tabular-nums}.fv-lora-row input[type=checkbox]{accent-color:var(--fv-accent);width:15px;min-height:15px} .fv-lora-row[data-enabled=false]{opacity:.45}.fv-lora-order{grid-column:2/5;display:flex;gap:5px;justify-content:flex-end} +.fv-lora-row .fv-lora-trigger{grid-column:2/5;grid-row:2;width:100%;min-width:0;min-height:28px;height:28px;box-sizing:border-box;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);padding:4px 8px;background:var(--fv-card);color:var(--fv-ink);font:inherit;font-size:var(--fv-micro)} +.fv-lora-trigger::placeholder{color:var(--fv-disabled)} .fv-studio .fv-lora-row button,.fv-lora-panel .fv-lora-row button{min-height:28px;padding:3px 7px}.fv-lora-note{font-size:var(--fv-micro);color:var(--fv-muted);text-align:center;margin:10px 0 0}.fv-lora-add{display:flex;justify-content:center;gap:8px} .fv-studio .fv-error{color:var(--fv-danger)}.fv-studio .fv-legacy{font-size:var(--fv-micro);color:var(--fv-muted);padding:12px 0} /* The workspace fills the window. On wide windows the settings column widens a little and the diff --git a/web/studio.js b/web/studio.js index 8d9db685..23b978a2 100644 --- a/web/studio.js +++ b/web/studio.js @@ -18,6 +18,7 @@ import { createRecentVideos } from './studio_recent.js'; import { createAgentActivity } from './agent_activity.js'; import { createStudioQueue, randomSeed } from './studio_queue.js'; import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs } from './prompt_references.js'; +import { reconcileLoraTriggers } from './lora_triggers.js'; import { createSamplingEffort } from './sampling_effort.js'; import { resultActions, previewBadge, icon } from './result_actions.js'; import { createPromptEnhancer } from './prompt_enhance.js'; @@ -60,6 +61,63 @@ function view(file, type = 'input') { } const number = (n, divisor = 1, unit = 's') => Number.isFinite(n) && n >= 0 ? `${(n / divisor).toFixed(1)} ${unit}` : '—'; +const LORA_TRIGGERS = 'freevideo_lora_triggers'; +// Node and creation panels share lookups and serialize saves for each filename. +const loraTriggers = new Map(); +function triggerEntry(name) { + if (!loraTriggers.has(name)) loraTriggers.set(name, {version: 0}); + return loraTriggers.get(name); +} +// The server's own wording is technical; the panel always says the same plain sentence. +const triggerReadError = () => new Error(t('Could not read trigger words.', '无法读取触发词。')); +const triggerSaveError = () => new Error(t('Could not save. Try again.', '没有保存,请再试一次。')); +async function triggerResponse(response, fail) { + let result = null; + try { result = await response.json(); } catch { /* not JSON: same sentence */ } + if (!response.ok || typeof result?.words !== 'string') throw fail(); + return result; +} +function announceTriggers(name) { + window.dispatchEvent(new CustomEvent('freevideo-lora-triggers', {detail: name})); +} +function loadTriggers(name) { + const entry = triggerEntry(name); + if (entry.data && Date.now() - entry.updated < 60000) return Promise.resolve(entry.data); + if (entry.loading) return entry.loading; + const version = entry.version; + entry.loading = (async () => { + // The first read of a large file hashes it once on the server; keep asking while that finishes. + let result; + for (let attempt = 0; ; attempt++) { + try { + result = await triggerResponse(await api.fetchApi('/freevideo/lora/triggers?' + new URLSearchParams({name}), + {cache: 'no-store', signal: AbortSignal.timeout(6500)}), triggerReadError); + break; + } catch (error) { + if (error?.name === 'TimeoutError' && attempt < 9) continue; + throw triggerReadError(); + } + } + if (entry.version === version) { entry.data = result; entry.updated = Date.now(); announceTriggers(name); } + return result; + })().finally(() => { entry.loading = null; }); + return entry.loading; +} +function saveTriggers(name, words) { + const entry = triggerEntry(name), version = ++entry.version; + entry.saving = (entry.saving || Promise.resolve()).catch(() => {}).then(async () => { + let result; + try { + result = await triggerResponse(await api.fetchApi('/freevideo/lora/triggers', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, words: words.trim()}), + }), triggerSaveError); + } catch { throw triggerSaveError(); } + if (entry.version === version) { entry.data = result; entry.updated = Date.now(); announceTriggers(name); } + return result; + }); + return entry.saving; +} + export const loraWarning = () => t( 'FreeVideo needs no acceleration LoRA.', 'FreeVideo无需添加加速LoRA'); @@ -78,16 +136,59 @@ export function promptGuide() { return note; } -export function loraPanel(node, panel) { +export function loraPanel(node, panel, {promptNode = null} = {}) { const list = el('div', null, 'fv-lora-list'), note = el('p', '', 'fv-lora-note'); const actions = el('div', null, 'fv-lora-add'); panel.append(list, actions, note); - let names = [], disposed = false; + let names = [], disposed = false, rendering = false, renderAgain = false; + const drafts = new Map(), errors = new Map(), saves = new Map(); const read = () => { const rows = JSON.parse(value(node, 'adapters') || '[]'); if (!Array.isArray(rows) || rows.length > 32) throw new Error(t('Use at most 32 LoRAs', '最多使用 32 个 LoRA')); return rows; }; - const write = rows => { set(node, 'adapters', JSON.stringify(rows)); window.dispatchEvent(new CustomEvent('freevideo-loras', {detail: node.id})); }; + function syncPrompt(rows) { + const targets = new Set([promptNode, ...(node.graph?._nodes || []).filter(target => + (target.type === 'FreeVideoGenerate' || target.comfyClass === 'FreeVideoGenerate') && upstream(target, 'loras') === node)].filter(Boolean)); + const words = Object.fromEntries(rows.filter(row => triggerEntry(row.name).data) + .map(row => [row.name, triggerEntry(row.name).data.words])); + for (const target of targets) { + if (!widget(target, 'text')) continue; + const saved = target.properties?.[LORA_TRIGGERS]; + const previous = Array.isArray(saved) ? saved : []; + const next = reconcileLoraTriggers(value(target, 'text'), previous, rows, words); + if (linked(target, 'text')) { + // Never claim an insertion that cannot be written to a linked prompt. + next.records = next.records.filter(row => previous.some(old => old.name === row.name && old.words === row.words)); + } else if (next.prompt !== value(target, 'text')) { + set(target, 'text', next.prompt); + syncReferencePrompt(target, t); + // Encoded conditioning skips reference remapping, but both text + // editors still display edits to the unlinked prompt widget. + if (linked(target, 'conditioning')) window.dispatchEvent(new CustomEvent('freevideo-reference-prompt', {detail: target.id})); + } + if (JSON.stringify(previous) !== JSON.stringify(next.records)) { + (target.properties ||= {})[LORA_TRIGGERS] = next.records; + target.graph?.change(); target.setDirtyCanvas?.(true, true); + } + } + } + function refreshTriggerWords(name) { + if (disposed) return; + try { + const rows = read(); + if (!rows.some(row => row.name === name)) return; + syncPrompt(rows); + for (const trigger of list.querySelectorAll('.fv-lora-trigger')) { + if (trigger.dataset.name === name && !drafts.has(name)) trigger.value = triggerEntry(name).data?.words ?? ''; + } + note.textContent = rows.map(row => errors.get(row.name)).find(Boolean) || loraWarning(); + } catch (error) { note.textContent = error.message; } + } + const write = rows => { if (set(node, 'adapters', JSON.stringify(rows))) { syncPrompt(rows); window.dispatchEvent(new CustomEvent('freevideo-loras', {detail: node.id})); } }; function render() { - if (disposed) return; list.replaceChildren(); + if (disposed) return; + if (rendering) { renderAgain = true; return; } + rendering = true; try { + list.replaceChildren(); const rows = read(); + syncPrompt(rows); rows.forEach((row, index) => { const wrap = el('div', null, 'fv-lora-row'); wrap.dataset.enabled = String(row.enabled !== false); const enabled = el('input'); enabled.type = 'checkbox'; enabled.checked = row.enabled !== false; @@ -97,9 +198,31 @@ export function loraPanel(node, panel) { select.value = row.name; select.title = row.name; select.onchange = () => { row.name = select.value; write(rows); }; const strength = el('input'); strength.type = 'number'; strength.min = '-4'; strength.max = '4'; strength.step = '.05'; strength.value = row.strength ?? 1; strength.setAttribute('aria-label', t('LoRA strength', 'LoRA 强度')); strength.onchange = () => { if (strength.reportValidity() && strength.value !== '') { row.strength = Number(strength.value); write(rows); } }; - select.disabled = strength.disabled = row.enabled === false; + const trigger = el('input', null, 'fv-lora-trigger'); trigger.type = 'text'; trigger.maxLength = 300; + trigger.dataset.name = row.name; + trigger.placeholder = t('Trigger words', '触发词'); trigger.setAttribute('aria-label', trigger.placeholder); + trigger.value = drafts.get(row.name) ?? triggerEntry(row.name).data?.words ?? ''; + trigger.oninput = () => drafts.set(trigger.dataset.name, trigger.value); + trigger.onchange = async () => { + // Switching models can blur the old field after row.name changes. + const name = trigger.dataset.name, draft = trigger.value; + const request = Symbol(); saves.set(name, request); + drafts.set(name, draft); errors.delete(name); + try { + await saveTriggers(name, draft); + if (saves.get(name) !== request) return; + errors.delete(name); + if (drafts.get(name) === draft) drafts.delete(name); + refreshTriggerWords(name); + } catch (error) { + if (!disposed && saves.get(name) === request && drafts.get(name) === draft) { + errors.set(name, error.message); refreshTriggerWords(name); + } + } + }; + select.disabled = strength.disabled = trigger.disabled = row.enabled === false; const remove = button('×', () => { rows.splice(index, 1); write(rows); }); remove.setAttribute('aria-label', t('Remove LoRA', '移除 LoRA')); - wrap.append(enabled, select, strength, remove); + wrap.append(enabled, select, strength, remove, trigger); if (rows.length > 1) { const order = el('div', null, 'fv-lora-order'); for (const [label, delta] of [['↑', -1], ['↓', 1]]) { @@ -110,9 +233,17 @@ export function loraPanel(node, panel) { wrap.append(order); } list.append(wrap); + loadTriggers(row.name).catch(error => { if (!disposed && trigger.isConnected) note.textContent = error.message; }); }); - note.textContent = loraWarning(); - } catch (error) { note.textContent = error.message; } + note.textContent = rows.map(row => errors.get(row.name)).find(Boolean) || loraWarning(); + } catch (error) { + if (error instanceof DOMException) throw error; + note.textContent = error.message; + } + finally { + rendering = false; + if (renderAgain) { renderAgain = false; queueMicrotask(render); } + } } const add = button(t('Add', '添加'), async () => { await refresh(); // The folder may have changed since the list was last read. @@ -125,11 +256,13 @@ export function loraPanel(node, panel) { actions.append(add); const changed = e => { if (String(e.detail) === String(node.id)) render(); }; window.addEventListener('freevideo-loras', changed); + const triggersChanged = e => refreshTriggerWords(e.detail); + window.addEventListener('freevideo-lora-triggers', triggersChanged); const configure = node.onConfigure; // Only the persistent node panel participates in graph loading. - if (panel.classList.contains('fv-lora-panel')) node.onConfigure = function (...args) { const result = configure?.apply(this, args); render(); return result; }; + if (panel.classList.contains('fv-lora-panel')) node.onConfigure = function (...args) { const result = configure?.apply(this, args); queueMicrotask(render); return result; }; render(); refresh(); - const dispose = () => { disposed = true; window.removeEventListener('freevideo-loras', changed); }; + const dispose = () => { disposed = true; window.removeEventListener('freevideo-loras', changed); window.removeEventListener('freevideo-lora-triggers', triggersChanged); }; dispose.refresh = refresh; return dispose; } @@ -143,7 +276,9 @@ function pendingLoras(node, existing = null) { const LiteGraph = globalThis.LiteGraph; if (!(slot >= 0) || !node.graph || (!existing && !LiteGraph?.registered_node_types?.FreeVideoLoRAStack)) return null; let stack = existing; - const adapters = {name: 'adapters', value: existing ? value(existing, 'adapters') || '[]' : '[]', callback(next) { + let adaptersValue = existing ? value(existing, 'adapters') || '[]' : '[]'; + const adapters = {name: 'adapters', get value() { return stack ? value(stack, 'adapters') || '[]' : adaptersValue; }, + set value(next) { adaptersValue = next; }, callback(next) { let rows; try { rows = JSON.parse(next); } catch { return; } const graph = node.graph, active = Array.isArray(rows) && rows.length > 0; if (active && !stack) { @@ -164,7 +299,7 @@ function pendingLoras(node, existing = null) { graph.change?.(); window.dispatchEvent(new CustomEvent('freevideo-loras', {detail: stack.id})); }}; - return {id: 'creation-lora', graph: node.graph, widgets: [adapters], inputs: []}; + return {get id() { return stack?.id ?? 'creation-lora'; }, graph: node.graph, widgets: [adapters], inputs: []}; } let current; @@ -259,7 +394,7 @@ export function openStudio(node, {extendFrom = null} = {}) { return {seconds: Number(value(node, 'seconds')), media: items.map(row => ({file: row.file, role: row.role || 'reference'}))}; }}); cleanup.push(() => enhancer.dispose()); - const promptChanged = e => { if (String(e.detail) === String(node.id)) { if (prompt.value !== shown(value(node, 'text'))) prompt.value = shown(value(node, 'text')); placeholder(); references.refresh(); enhancer.sync(); } }; + const promptChanged = e => { if (String(e.detail) === String(node.id)) { if (prompt.value !== shown(value(node, 'text'))) prompt.value = shown(value(node, 'text')); prompt.disabled = linked(node, 'text'); mention.disabled = prompt.disabled; placeholder(); references.refresh(); enhancer.sync(); } }; window.addEventListener('freevideo-reference-prompt', promptChanged); cleanup.push(() => window.removeEventListener('freevideo-reference-prompt', promptChanged)); const promptSection = section(t('Describe your scene', '描述画面')); @@ -371,7 +506,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const loraNode = connectedLora?.properties?.[CREATED_BY_PANEL] ? pendingLoras(node, connectedLora) : connectedLora || pendingLoras(node); const [loraDetails, loraMount] = expand(t('LoRA', 'LoRA')); if (loraNode && widget(loraNode, 'adapters')) { - const stop = loraPanel(loraNode, loraMount); cleanup.push(stop); + const stop = loraPanel(loraNode, loraMount, {promptNode: node}); cleanup.push(stop); loraDetails.addEventListener('toggle', () => { if (loraDetails.open) stop.refresh(); }); loraDetails.open = JSON.parse(value(loraNode, 'adapters') || '[]').length > 0; } From 1e43b6a4abb070f8489f52b9738938bf4cba9ea6 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 08:21:21 +0000 Subject: [PATCH 10/53] Agent conversations: delete from the row menu; a deleting one takes no new work --- freevideo_engine/agent/errors.py | 11 ++ freevideo_engine/agent/jobs.py | 4 + freevideo_engine/agent/productions.py | 2 + freevideo_engine/agent/routes.py | 36 ++++++- freevideo_engine/agent/schema.py | 1 + freevideo_engine/agent/sessions.py | 43 +++++++- web/autopilot_chat.css | 25 ++++- web/autopilot_chat.js | 147 ++++++++++++++++++++++++-- web/autopilot_conversations.js | 14 +++ 9 files changed, 270 insertions(+), 13 deletions(-) diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index a0b44920..027f35f9 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -164,7 +164,16 @@ } +# A conversation that is not there: the user may delete one while the agent still works or waits in it. +SESSION_GONE = ('This conversation does not exist; the user may have deleted it.', + 'Do not repeat elsewhere what you were saying or doing for it. Call the wait again without a session ' + '(GET /freevideo/agent/v1/inbox?wait=50; MCP: freevideo_wait without id) and carry on in the session ' + 'it names.') + + def recovery(code, *, kind=None, stage='request', resumable=True): + if kind == 'session' and code == 'not_found': + return SESSION_GONE[1] if kind == 'image' and code == 'pack_missing': return ERRORS[code][3] if kind == 'image' and code == 'no_memory': @@ -210,6 +219,8 @@ def build(code, *, details=None, request=None, validation=False, message=None, n action = recovery(code, kind=kind, stage=stage, resumable=resumable) if kind == 'image': default_message = IMAGE_ERRORS.get(code, default_message) + if kind == 'session' and code == 'not_found': + default_message = SESSION_GONE[0] result = dict(code=code, message=message if validation and message else default_message, details=(details or []) if validation else safe_details(details or []), retryable=retryable, request_id=request or request_id(), diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 4384f12e..c5623e0c 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -455,6 +455,8 @@ def __init__(self, port, directory, *, clock=now): # Optional executor hook: called as parent_allowed(row['parent']) before a # child with a parent may start. A False answer records the job as cancelled. self.parent_allowed = None + # Optional: called as session_open(sid) while work for a conversation is committed; raises if it is gone. + self.session_open = None from .projects import Projects # The page keeps its projects under ComfyUI's output folder; loading may already poll project jobs. self.projects = Projects(port.output_root) @@ -765,6 +767,8 @@ def submit_request(self, value, *, client=None, key=None, parent=None, editor=No if receipt: receipt['jobs'] = [r['id'] for r in rows] rows[0]['receipts'] = [receipt] + if self.session_open is not None and rows and rows[0].get('session'): + self.session_open(rows[0]['session']) # Deleting a conversation checks for work under this same lock. self.save_batch(rows) self.jobs.update({r['id']: r for r in rows}) return rows, False diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index f752ab56..8d21f098 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -560,6 +560,8 @@ def start(self, request, *, by='agent', ref=None, local=True, client=None): if ref: record['receipts'].append(dict(key=ref, digest=digest, result=result, **({'client': client} if client else {}))) + if self.jobs.session_open is not None and record.get('session'): + self.jobs.session_open(record['session']) # Deleting a conversation checks for films under this same lock. self._save(record) if cards: self.jobs.cast.used(sorted(cards), identity) diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 00346783..f9736701 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -360,6 +360,7 @@ async def off(request): # Operations an agent runs only after the user approves them; the page changes the policy. confirmations = Confirmations(port.output_root / 'FreeVideo' / '.agent' / 'confirm.json', sessions) jobs.productions.gate = confirmations.gate + jobs.session_open = sessions.require_open except Exception as error: # Optional agent state must never prevent the studio or other routes from loading. logging.getLogger(__name__).warning('[FreeVideo] Agent startup unavailable: %s', error) @@ -979,6 +980,34 @@ def write(): request_id=errors.request_id())}, 409) return reply(done[1], done[0]) + @routes.post('/freevideo/agent/chat/delete') + @guarded + async def chat_delete(request): + value = await body(request) + def write(): + store = work_sessions() + before = chat_session(store) + sid = chat_session(store, value['session']) + if sid is None: + raise JobError('not_found', 'No conversation') + # Work in progress still writes into its conversation; finished videos stay in the library. + # New work is refused first, so what is counted below cannot grow before the file goes. + store.begin_delete(sid) + try: + with jobs.lock: # Productions share it. + if (any(r.get('session') == sid and r['status'] in ACTIVE for r in jobs.rows()) + or any(f['session'] == sid and not f['paused'] + for f in jobs.productions.list(status='active', limit=None))): + raise JobError('conflict', 'Work is in progress in this conversation') + store.delete(sid) + except BaseException: + store.end_delete(sid) + raise + chat['folds'].pop(sid, None) # The page cache held its whole text. + chat_moved(store, before) + return dict(deleted=sid, here=chat_session(store), agent=agent_state()) + return reply(await asyncio.to_thread(write)) + @routes.get('/freevideo/agent/chat/video') @guarded async def chat_video(request): @@ -1261,6 +1290,9 @@ async def disconnected(): after = row['seq'] except (ConnectionError, asyncio.CancelledError): pass + except JobError as error: + if error.code != 'not_found': + raise # A deleted conversation ends its stream; the next request for it says it is gone. finally: tasks = [task for task in (disconnect, pending) if task is not None] for task in tasks: @@ -1607,7 +1639,9 @@ def answer(value): try: while True: if follow: - sid = request['agent_session'] = await asyncio.to_thread(store.current_for, client) or sid + # The user may delete the conversation the agent waits in; with none left, it listens in a new one. + sid = request['agent_session'] = await asyncio.to_thread( + lambda: store.current_for(client) or (sid if store.has(client, sid) else store.create(client=client)['id'])) if store is not None and sid is not None: # Mark before reading so words arriving in between wake the wait below. def read(): diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 62f22f7a..767fe18d 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -386,6 +386,7 @@ def route(summary, body=None, query=None, **kw): ('/activity/cancel', 'POST', obj({'id': JOB}, ('id',))), ('/connect', 'GET', None), ('/connect/reset', 'POST', EMPTY), ('/chat', 'GET', None), ('/chat/now', 'GET', None), ('/chat/video', 'GET', None), ('/chat/list', 'GET', None), ('/chat/new', 'POST', EMPTY), + ('/chat/delete', 'POST', obj({'session': SESSION}, ('session',))), ('/chat', 'POST', obj({'text': string(8000), 'id': CHAT_ID, 'session': SESSION}, ('text', 'id'))), ('/chat/confirm', 'POST', obj({'id': string(12, pattern=r'^cf[0-9a-f]{10}$'), 'approve': {'type': 'boolean'}, 'session': SESSION}, ('id', 'approve')))): diff --git a/freevideo_engine/agent/sessions.py b/freevideo_engine/agent/sessions.py index 5fd002eb..adf49467 100644 --- a/freevideo_engine/agent/sessions.py +++ b/freevideo_engine/agent/sessions.py @@ -22,6 +22,8 @@ class SessionError(JobError): def view(self): if self.code == 'invalid_request': return errors.validation(self.details['details']) + if self.code == 'not_found': + return errors.build(self.code, kind='session') return super().view() @@ -65,6 +67,7 @@ def __init__(self, root: Path, *, clock=time.time, max_events=20000): self._chosen = set() self._waiters = {} self._partial = set() + self._closing = set() # Being deleted: no new work may start in them. self.root.mkdir(parents=True, exist_ok=True) self._load() @@ -168,6 +171,42 @@ def create(self, *, title=None, client=None, by='agent') -> dict: self._chosen.add(sid) return deepcopy(self._meta[sid]) + def require_open(self, sid) -> None: + """not_found unless the conversation exists and is not being deleted; work calls this as it is committed.""" + with self.lock: + self._get(sid) + if sid in self._closing: + raise SessionError('not_found', 'Session does not exist') + + def begin_delete(self, sid) -> None: + """Refuse new work in the conversation until delete() or end_delete(); the caller then checks for work in it.""" + with self.lock: + self._get(sid) + self._closing.add(sid) + + def end_delete(self, sid) -> None: + with self.lock: + self._closing.discard(sid) + + def delete(self, sid) -> None: + """Forget a conversation and remove its file. A wait in it ends empty; anything later says not_found.""" + with self.lock: + self._get(sid) + self._path(sid).unlink(missing_ok=True) + self._closing.discard(sid) + waiters = self._waiters.pop(sid, ()) + for kept in (self._meta, self._ids, self._clients, self._initial_seq, self._user_seq, self._acted, self._said): + kept.pop(sid, None) + self._chosen.discard(sid) + self._partial.discard(sid) + for key in [key for key in self._readers if key[0] == sid]: + del self._readers[key] + for loop, signal in waiters: + try: + loop.call_soon_threadsafe(signal.set) + except RuntimeError: + pass # A closed client loop no longer needs notification. + def list(self, *, limit=50, before=None) -> dict: _check(schema.session_schema('list'), dict(limit=limit, **({'before': before} if before is not None else {}))) with self.lock: @@ -281,6 +320,8 @@ def register(): def read(): with self.lock: + if sid not in self._meta: + return [], None # Deleted while waiting. rows = self._events(sid, cursor, self.max_events) found = [row for row in rows if (types is None or row['type'] in types) and (by is None or row['by'] == by)] @@ -311,7 +352,7 @@ async def offload(work): # after the scan schedule set() on the loop and cannot lose a wakeup. signal.clear() found, cursor = await offload(read) - if found: + if found or cursor is None: return found if leave is not None and await offload(leave): return [] diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index ea0c8026..10d32eb2 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -4,7 +4,7 @@ .fv-chat[hidden],.fv-chat [hidden]{display:none} /* List dimensions are kept here for art review; the breakpoint below is shared with the drawer's keyboard behavior. */ .fv-chat[data-conversations=true]{grid-template-columns:var(--fv-chats-width) minmax(0,1fr)} -.fv-chat-conversations{box-sizing:border-box;min-height:0;min-width:0;display:flex;flex-direction:column;gap:var(--fv-chats-gap);padding:16px var(--fv-chats-pad);border-right:1px solid var(--fv-border);background:var(--fv-bg)} +.fv-chat-conversations{position:relative;box-sizing:border-box;min-height:0;min-width:0;display:flex;flex-direction:column;gap:var(--fv-chats-gap);padding:16px var(--fv-chats-pad);border-right:1px solid var(--fv-border);background:var(--fv-bg)} .fv-chat-conversation-list{min-height:0;overflow:auto;display:grid;align-content:start;gap:var(--fv-chats-gap)} .fv-studio .fv-chat-conversations button{box-sizing:border-box;flex:none;justify-content:flex-start;min-width:0;width:100%;min-height:var(--fv-chats-row);padding:8px var(--fv-chats-pad);border-radius:var(--fv-chats-radius);border:1px solid transparent;background:transparent;font-size:var(--fv-chats-font);font-weight:var(--fv-regular);color:var(--fv-ink);text-align:left;gap:10px} .fv-studio .fv-chat-conversations .fv-chat-new{border-color:var(--fv-border);margin-bottom:6px} @@ -12,6 +12,25 @@ .fv-studio .fv-chat-conversations button:focus-visible{outline-offset:-2px} .fv-studio .fv-chat-conversation[aria-current]{background:var(--fv-raised);border-color:var(--fv-accent-dim)} .fv-chat-conversation span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* The row button keeps its full frame; the inset action only takes space from the title. */ +.fv-chat-conversation-row{position:relative;min-width:0;display:flex;align-items:center;border-radius:var(--fv-chats-radius)} +.fv-chat-conversation-row:is(:hover,:focus-within,[data-menu-open=true]){background:var(--fv-raised)} +.fv-studio .fv-chat-conversations .fv-chat-conversation{flex:1;width:100%} +.fv-studio .fv-chat-conversation-row:has(> .fv-chat-more:not([hidden])):is(:hover,:focus-within,[data-menu-open=true]) .fv-chat-conversation{padding-right:calc(var(--fv-chats-pad) + 28px)} +.fv-studio .fv-chat-conversations .fv-chat-more{position:absolute;right:1px;top:50%;transform:translateY(-50%);width:0;height:28px;min-height:28px;padding:0;border:0;overflow:hidden;justify-content:center;color:var(--fv-muted);opacity:0;pointer-events:none} +.fv-studio .fv-chat-conversation-row:is(:hover,:focus-within,[data-menu-open=true]) .fv-chat-more{width:28px;opacity:1;pointer-events:auto} +.fv-studio .fv-chat-conversations .fv-chat-more:hover:not(:disabled){background:transparent;color:var(--fv-ink)} +.fv-chat-more svg{flex:none} +/* Menu values match .fv-menu / .fv-menu-item in result_actions.css (lines 16–18). */ +.fv-chat-menu{box-sizing:border-box;position:absolute;z-index:5;display:grid;width:max-content;max-width:calc(100% - 2px);max-height:100%;overflow:auto;padding:6px;border:1px solid var(--fv-border);border-radius:13px;background:var(--fv-raised);box-shadow:0 14px 36px #00000073;animation:fv-chat-menu-enter .16s ease} +.fv-chat-menu-layer{grid-area:1/1;display:flex;min-width:0} +.fv-chat-menu-layer[data-active=false]{visibility:hidden;pointer-events:none} +.fv-studio .fv-chat-menu .fv-chat-menu-item{flex:1;width:auto;min-height:0;padding:9px 11px;border:0;border-radius:9px;background:none;color:var(--fv-ink);font-size:13px;white-space:nowrap} +.fv-studio .fv-chat-menu .fv-chat-menu-item:is(:hover,:focus-visible){background:var(--fv-hover);outline:none} +.fv-studio .fv-chat-menu .fv-chat-menu-delete{color:var(--fv-danger)} +.fv-studio .fv-chat-menu .fv-chat-menu-delete:is(:hover,:focus-visible){background:var(--fv-danger-subtle)} +@keyframes fv-chat-menu-enter{from{opacity:0}} +@media(hover:none){.fv-studio .fv-chat-conversations .fv-chat-conversation-row:has(> .fv-chat-more:not([hidden])) .fv-chat-conversation{padding-right:calc(var(--fv-chats-pad) + 36px)}.fv-studio .fv-chat-conversations .fv-chat-more,.fv-studio .fv-chat-conversation-row:is(:hover,:focus-within,[data-menu-open=true]) .fv-chat-more{width:36px;height:36px;min-height:36px;opacity:1;pointer-events:auto}} .fv-chat-running{flex:none;width:var(--fv-chats-dot);height:var(--fv-chats-dot);border-radius:50%;background:var(--fv-accent)} .fv-chat-tools{display:none} .fv-chat-scroll{min-height:0;overflow:auto;margin:0 -40px;padding:24px 40px 2px;scrollbar-gutter:stable both-edges} @@ -83,6 +102,8 @@ .fv-studio .fv-chat-send:disabled{opacity:1;background:#22344a;color:#9fb3c8} .fv-studio .fv-chat-send:focus-visible{outline-offset:2px} @media(max-width:720px){ + .fv-studio .fv-chat-conversations .fv-chat-conversation-row:has(> .fv-chat-more:not([hidden])) .fv-chat-conversation{padding-right:calc(var(--fv-chats-pad) + 36px)} + .fv-studio .fv-chat-conversations .fv-chat-more,.fv-studio .fv-chat-conversation-row:is(:hover,:focus-within,[data-menu-open=true]) .fv-chat-more{width:36px;height:36px;min-height:36px;opacity:1;pointer-events:auto} .fv-chat[data-conversations=true]{grid-template-columns:minmax(0,1fr)} .fv-chat[data-conversations=true] .fv-chat-main{grid-template-rows:auto minmax(0,1fr) auto} .fv-chat-tools:not([hidden]){display:flex;padding-top:12px;margin-bottom:-12px} @@ -93,4 +114,4 @@ } @media(max-width:480px){.fv-chat-main{padding:0 14px 14px}.fv-chat-scroll{margin:0 -14px;padding:16px 14px 2px}.fv-chat-hello{padding-top:32px}.fv-chat-job{max-width:none;gap:12px}.fv-chat-confirm{max-width:none} .fv-chat-preview,.fv-chat-preview[data-shape=wide]{width:98px}.fv-chat-length{display:none}.fv-chat-percent{font-size:22px}.fv-studio.fv-autopilot .fv-chat-input textarea{font-size:16px}.fv-studio .fv-chat-down{right:14px;bottom:84px}} -@media(prefers-reduced-motion:reduce){.fv-chat-water,.fv-chat-percent,.fv-chat-parts>i::after{transition:none}.fv-chat-water::after,.fv-chat-dots>i,.fv-chat-preview>img{animation:none}} +@media(prefers-reduced-motion:reduce){.fv-chat-water,.fv-chat-percent,.fv-chat-parts>i::after{transition:none}.fv-chat-water::after,.fv-chat-dots>i,.fv-chat-preview>img,.fv-chat-menu{animation:none}} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 4184f844..db97647c 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -2,7 +2,7 @@ // cards at the place they happened. Reads and sends use the page session; the agent's token never enters the page. import {agentGet, agentPost} from './agent_session.js'; import { estimatedFraction, remainingAbove, remainingShown } from './generation_progress.js'; -import {emptyConversations, hasConversations, mergeConversations, runningElsewhere, selectConversation} from './autopilot_conversations.js'; +import {canDeleteConversation, conversationMenu, emptyConversations, hasConversations, mergeConversations, removeConversation, runningElsewhere, selectConversation} from './autopilot_conversations.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./autopilot_chat.css', import.meta.url).href; document.head.append(css); @@ -164,33 +164,145 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () chats.setAttribute('aria-controls', sidebar.id); chats.setAttribute('aria-expanded', 'false'); tools.hidden = true; tools.append(chats); main.prepend(tools); sidebar.append(newChat, list); element.prepend(sidebar); const listNodes = new Map(), narrow = () => window.matchMedia('(max-width: 720px)').matches; + let menuState = null, changingMenu = false; + const deleting = new Set(), deleted = new Set(); + // One overlay outside the scrolling list, positioned in the sidebar's own coordinates. + const menu = el('div', 'fv-chat-menu'), first = el('div', 'fv-chat-menu-layer'), confirmation = el('div', 'fv-chat-menu-layer fv-chat-menu-confirm'); + menu.id = `${sidebar.id}-menu`; menu.setAttribute('role', 'menu'); menu.hidden = true; + const remove = press('fv-chat-menu-item', t('Delete', '删除'), () => { + menuState = conversationMenu(conversations, menuState, 'confirm'); + updateMenu(true); + }); + const yes = press('fv-chat-menu-item fv-chat-menu-delete', t('Delete', '删除'), () => void deleteChat(menuState?.session)); + const cancel = press('fv-chat-menu-item', t('Cancel', '取消'), () => closeMenu(true)); + for (const button of [remove, yes, cancel]) button.setAttribute('role', 'menuitem'); + first.append(remove); confirmation.append(yes, cancel); menu.append(first, confirmation); sidebar.append(menu); + const menuOrigin = () => listNodes.get(menuState?.session)?.more; + const inMenu = target => menu.contains(target) || menuOrigin()?.contains(target); + const leaveMenu = event => { if (menuState && !changingMenu && !inMenu(event.relatedTarget)) closeMenu(); }; + menu.addEventListener('focusout', leaveMenu); function drawer(on, restore = false) { + if (!on) closeMenu(); element.dataset.chatsOpen = String(on); chats.setAttribute('aria-expanded', String(on)); // Only the overlay needs to keep keyboard focus in its list. main.inert = on && narrow(); if (on) (list.querySelector('[aria-current]') || newChat).focus(); else if (restore) chats.focus(); } + function positionMenu() { + const node = listNodes.get(menuState?.session); + if (!node) return; + const bounds = sidebar.getBoundingClientRect(), anchor = node.more.getBoundingClientRect(), row = node.row.getBoundingClientRect(); + // Local dimensions also handle the enclosing dialog's entry scale without fixed-position offsets. + const sx = bounds.width / (sidebar.offsetWidth || bounds.width) || 1, sy = bounds.height / (sidebar.offsetHeight || bounds.height) || 1; + const left = bounds.left + (sidebar.clientLeft || 0) * sx, top = bounds.top + (sidebar.clientTop || 0) * sy; + const width = sidebar.clientWidth, height = sidebar.clientHeight || bounds.height / sy; + const mw = menu.offsetWidth || menu.getBoundingClientRect().width / sx, mh = menu.offsetHeight || menu.getBoundingClientRect().height / sy; + const x0 = Math.max(0, -left / sx), x1 = Math.min(width, ((window.innerWidth || bounds.right) - left) / sx); + const y0 = Math.max(0, -top / sy), y1 = Math.min(height, ((window.innerHeight || bounds.bottom) - top) / sy); + const below = (row.bottom - top) / sy, above = (row.top - top) / sy - mh; + menu.style.left = `${Math.max(x0, Math.min((anchor.right - left) / sx - mw, x1 - mw))}px`; + menu.style.top = `${Math.max(y0, Math.min(below + mh <= y1 ? below : above, y1 - mh))}px`; + } + function updateMenu(focus = false) { + changingMenu = true; + try { + menu.hidden = !menuState; + const confirming = menuState?.stage === 'confirm'; + first.dataset.active = String(!confirming); confirmation.dataset.active = String(confirming); + first.inert = !!confirming; confirmation.inert = !confirming; + remove.tabIndex = confirming ? -1 : 0; yes.tabIndex = cancel.tabIndex = confirming ? 0 : -1; + yes.disabled = cancel.disabled = deleting.has(menuState?.session); + for (const [id, node] of listNodes) { + const opened = menuState?.session === id; + node.row.dataset.menuOpen = String(opened); node.more.setAttribute('aria-expanded', String(opened)); + } + if (menuState) { + positionMenu(); + if (focus) (confirming ? cancel : remove).focus({preventScroll: true}); + } + } finally { changingMenu = false; } + } + function closeMenu(restore = false) { + const origin = menuOrigin(); + menuState = conversationMenu(conversations, menuState, 'close'); updateMenu(); + if (restore && origin && !origin.hidden) origin.focus({preventScroll: true}); + } + function openMenu(session) { + if (menuState?.session === session) { closeMenu(); return; } + if (deleting.has(session)) return; + menuState = conversationMenu(conversations, menuState, 'open', session); updateMenu(true); + } function renderConversations() { - const visible = hasConversations(conversations); + const previous = menuState; + const lostFocus = previous && inMenu(document.activeElement); + menuState = conversationMenu(conversations, menuState); + // After the final deletion, keep New chat reachable as the keyboard destination. + const visible = hasConversations(conversations) || conversations.supported === true && deleted.size > 0; element.dataset.conversations = String(visible); sidebar.hidden = tools.hidden = !visible; if (!visible) drawer(false); newChat.disabled = creating; chatsDot.hidden = !conversations.conversations.some(row => row.running > 0); const keep = new Set(conversations.conversations.map(row => row.id)); - for (const [id, node] of listNodes) if (!keep.has(id)) { node.button.remove(); listNodes.delete(id); } + for (const [id, node] of listNodes) if (!keep.has(id)) { node.row.remove(); listNodes.delete(id); } conversations.conversations.forEach((row, index) => { let node = listNodes.get(row.id); if (!node) { + const container = el('div', 'fv-chat-conversation-row'); const button = press('fv-chat-conversation', null, () => chooseChat(row.id)), dot = el('i', 'fv-chat-running'), title = el('span'); button.dataset.session = row.id; dot.setAttribute('aria-hidden', 'true'); button.append(dot, title); - listNodes.set(row.id, node = {button, dot, title}); + const more = press('fv-chat-more', null, () => openMenu(row.id)); + more.title = t('More', '更多'); more.setAttribute('aria-haspopup', 'menu'); more.setAttribute('aria-controls', menu.id); + more.append(glyph('M5 12h.01M12 12h.01M19 12h.01', 3.5)); + more.addEventListener('focusout', leaveMenu); container.append(button, more); + listNodes.set(row.id, node = {row: container, button, dot, title, more}); } node.title.textContent = row.title; node.button.title = row.title; node.dot.hidden = row.running <= 0; + node.more.setAttribute('aria-label', t(`More: ${row.title}`, `更多:${row.title}`)); + node.more.hidden = !canDeleteConversation(row); if (row.id === conversations.current) node.button.setAttribute('aria-current', 'true'); else node.button.removeAttribute('aria-current'); - if (list.children[index] !== node.button) list.insertBefore(node.button, list.children[index] || null); + if (list.children[index] !== node.row) list.insertBefore(node.row, list.children[index] || null); }); + updateMenu(); + if (lostFocus && !menuState) (listNodes.get(previous.session)?.button || (visible ? newChat : input)).focus({preventScroll: true}); + } + async function deleteChat(session) { + if (!menuState || deleting.has(session) || menuState.session !== session || menuState.stage !== 'confirm' || !active()) return; + const ids = conversations.conversations.map(row => row.id), index = ids.indexOf(session); + const neighbor = ids[index + 1] || ids[index - 1]; + deleting.add(session); + // A disabled focused button can lose native focus. Keep it on the origin while awaiting the request. + menuOrigin()?.focus({preventScroll: true}); updateMenu(); + try { + const response = await agentPost(api, '/freevideo/agent/chat/delete', {session}); + if (stopped) return; + const data = await response.json().catch(() => ({})); + if (stopped) return; + if (response.ok || response.status === 404 && data.error?.code === 'not_found') { + const current = conversations.current === session; + deleted.add(session); conversations = removeConversation(conversations, session, data.here); + if (menuState?.session === session) closeMenu(); + if (current) { + selectedSession = undefined; selection++; focusSession = null; away = null; + const agent = data.agent || state.agent; + resetChat(); state.agent = agent; input.value = ''; grow(); render(); report(); void poll(); + } + renderConversations(); + (listNodes.get(neighbor)?.button || listNodes.values().next().value?.button || newChat).focus({preventScroll: true}); + void readConversations(true); + } else if (response.status === 409) { + if (menuState?.session === session) closeMenu(); + if (data.error?.code === 'not_connected') { state = {...state, agent: {connected: false, name: null}}; render(); report(); } + else void readConversations(true); + } + } catch { /* Keep the row available for another try, without interrupting the conversation. */ } + finally { + deleting.delete(session); + if (menuState?.session === session) closeMenu(); + if (!stopped) renderConversations(); + } } function chooseChat(session, focus = false) { + closeMenu(); selectedSession = session; selection++; pinned = true; away = null; conversations = selectConversation(conversations, session); renderConversations(); input.value = ''; grow(); drawer(false); @@ -199,6 +311,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () render(); void readConversations(true); void poll(); } async function startChat() { + closeMenu(); if (creating || !active() || conversations.supported !== true) return; creating = true; renderConversations(); render(); try { @@ -223,6 +336,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const data = response.ok ? await response.json() : null; if (stopped) return; conversations = mergeConversations(conversations, data, response.status); + // A list read started before a deletion must not put the deleted row back. + conversations = {...conversations, conversations: conversations.conversations.filter(row => !deleted.has(row.id))}; renderConversations(); if (conversations.supported === false && selectedSession !== undefined) { selectedSession = undefined; selection++; void poll(); } } catch { /* List failures do not interrupt reading or sending. */ } @@ -230,19 +345,33 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (listAgain) { listAgain = false; void readConversations(true); } } const outside = event => { + if (menuState && !inMenu(event.target)) closeMenu(); if (chats.getAttribute('aria-expanded') === 'true' && !sidebar.contains(event.target) && !chats.contains(event.target)) drawer(false, true); }; const keys = event => { + if (event.key === 'Escape' && menuState) { + event.preventDefault(); event.stopImmediatePropagation(); closeMenu(true); return; + } + // Confirmation always cycles its two choices. In the drawer, the open menu owns Tab in both stages. + if (menuState && (event.key === 'Tab' && (narrow() || menuState.stage === 'confirm') + || menuState.stage === 'confirm' && ['ArrowLeft', 'ArrowRight'].includes(event.key))) { + event.preventDefault(); event.stopPropagation(); + const buttons = (menuState.stage === 'confirm' ? [yes, cancel] : [remove]).filter(button => !button.disabled); + const at = buttons.indexOf(document.activeElement), back = event.shiftKey || event.key === 'ArrowLeft'; + buttons[(at + (back ? -1 : 1) + buttons.length) % buttons.length]?.focus({preventScroll: true}); return; + } if (chats.getAttribute('aria-expanded') !== 'true') return; if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); drawer(false, true); } else if (event.key === 'Tab' && narrow()) { - const buttons = [...sidebar.querySelectorAll('button')].filter(button => !button.disabled); + const buttons = [...sidebar.querySelectorAll('button')].filter(button => !button.disabled && !button.hidden && !menu.contains(button) && !button.closest('[hidden]')); if (!buttons.length) return; if (event.shiftKey && document.activeElement === buttons[0]) { event.preventDefault(); buttons.at(-1).focus(); } else if (!event.shiftKey && document.activeElement === buttons.at(-1)) { event.preventDefault(); buttons[0].focus(); } } }; - const resize = () => { if (!narrow()) drawer(false); }; + const resize = () => { closeMenu(); if (!narrow()) drawer(false); }; + const listScroll = () => closeMenu(); + list.addEventListener('scroll', listScroll); document.addEventListener('pointerdown', outside, true); document.addEventListener('keydown', keys, true); window.addEventListener('resize', resize); // Whether the reader is at the newest entry. It holds while the conversation is out of sight, which forgets where it was scrolled. @@ -625,8 +754,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () await Promise.allSettled(ids.map(id => agentPost(api, '/freevideo/agent/activity/cancel', {id}))); poll(); }, - stop() { stopped = true; closed.abort(); clearInterval(moving); clearTimeout(timer); timer = null; + stop() { closeMenu(); stopped = true; closed.abort(); clearInterval(moving); clearTimeout(timer); timer = null; document.removeEventListener('visibilitychange', visible); document.removeEventListener('pointerdown', outside, true); - document.removeEventListener('keydown', keys, true); window.removeEventListener('resize', resize); + document.removeEventListener('keydown', keys, true); window.removeEventListener('resize', resize); list.removeEventListener('scroll', listScroll); log.querySelectorAll('video').forEach(video => video.pause()); }}; } diff --git a/web/autopilot_conversations.js b/web/autopilot_conversations.js index ce2baca9..9974dd15 100644 --- a/web/autopilot_conversations.js +++ b/web/autopilot_conversations.js @@ -4,6 +4,20 @@ export const selectConversation = (state, session) => ({...state, current: sessi export const hasConversations = state => state.supported === true && state.conversations.length > 0; // The first other conversation with work under way; null when there is none. export const runningElsewhere = (state, session) => state.conversations.find(row => row.running > 0 && row.id !== session) || null; +export const canDeleteConversation = row => !!row?.id && !(row.running > 0); +// The one menu moves from closed to Delete, then to Delete/Cancel. Snapshots can close either stage. +export function conversationMenu(state, menu, action = 'reconcile', session = null) { + const eligible = id => state.supported === true && state.conversations.some(row => row.id === id && canDeleteConversation(row)); + if (action === 'close') return null; + if (action === 'open') return eligible(session) ? {session, stage: 'delete'} : null; + if (!menu || !eligible(menu.session)) return null; + if (action === 'confirm' && menu.stage === 'delete') return {...menu, stage: 'confirm'}; + return menu; +} +// Only deleting the conversation being read changes the reader's destination. An unsaid `here` is valid too. +export const removeConversation = (state, session, here = null) => ({...state, + current: state.current === session ? here ?? null : state.current, + conversations: state.conversations.filter(row => row.id !== session)}); export function mergeConversations(state, data, status = 200) { // A legacy server is probed once per page; ordinary errors retain the last good snapshot. From a2f2eedd0bdf2c2c87376e6b9e0f03c92e9c5665 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 07:35:17 +0000 Subject: [PATCH 11/53] Editor: a failed step says what went wrong and offers the diagnostic report Editor: a failed step says what went wrong and offers the diagnostic report; presses never pass silently Generation failures in the editor use the same failure card as Create: the step that failed, the reason, and a button that downloads the diagnostic report and opens the same submit dialog. Export failures name the server message. Export, clip cards and the generate bar no longer sit disabled without a word: each press says why it waits. Undo and redo while a part generates say why nothing changed. Imported clips that are still preparing go on the timeline when ready. --- web/editor.css | 7 ++ web/editor.js | 246 +++++++++++++++++++++++++++++++++--------- web/editor_failure.js | 86 +++++++++++++++ web/editor_genbar.js | 35 +++++- web/editor_library.js | 57 +++++++--- web/editor_store.js | 12 ++- web/editor_tracks.css | 5 +- web/editor_tracks.js | 23 ++-- web/health.js | 2 +- 9 files changed, 389 insertions(+), 84 deletions(-) create mode 100644 web/editor_failure.js diff --git a/web/editor.css b/web/editor.css index 79cb4b07..ed5584c4 100644 --- a/web/editor.css +++ b/web/editor.css @@ -67,6 +67,12 @@ .fv-ed-genbar[data-mode=selected] .fv-ed-gen-hint{flex:none} .fv-ed-gen-note{font-size:13px;color:var(--fv-ink);flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-ed-gen-note[hidden]{display:none} +.fv-editor:has(.fv-ed-gen-failure:not([hidden])){--fv-ed-generation-row:max-content} +.fv-ed-genbar:has(.fv-ed-gen-failure:not([hidden])){flex-wrap:wrap;padding-top:10px;padding-bottom:10px} +.fv-editor .fv-ed-gen-failure{display:block;flex:1 0 100%;min-width:0} +.fv-ed-failure-actions{justify-content:flex-start;width:100%} +.fv-ed-failure-reason{flex:1 1 220px;min-width:0;color:var(--fv-warning);overflow-wrap:anywhere} +.fv-ed-gen-failure .fv-report-tip{width:100%} .fv-ed-basic-progress[data-state=waiting] .fv-ed-basic-percent{font-weight:500;color:var(--fv-muted)} .fv-ed-description{flex:1;width:0} .fv-ed-first-frame{display:flex;align-items:center;gap:8px;flex:none;font-size:13px;white-space:nowrap} @@ -164,6 +170,7 @@ /* Keep the editor's controls visible instead of clipping them at phone widths. */ @media(max-width:480px){ .fv-studio.fv-editor{grid-template-rows:auto minmax(96px,1fr) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,236px)} + .fv-studio.fv-editor:has(.fv-ed-gen-failure:not([hidden])){grid-template-rows:auto minmax(0,1fr) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,236px)} .fv-studio.fv-editor .fv-header:has(.fv-view-switch){grid-template-columns:minmax(0,1fr) auto} .fv-ed-toolbar{display:grid;grid-template-columns:auto minmax(0,1fr);height:auto;gap:4px 8px;padding:4px 12px;overflow:visible} .fv-ed-toolbar>.fv-ed-tool-group:nth-child(2){grid-column:2;grid-row:1/3;flex-wrap:wrap;align-self:start;min-width:0} diff --git a/web/editor.js b/web/editor.js index eacb1a8b..2efdc998 100644 --- a/web/editor.js +++ b/web/editor.js @@ -23,13 +23,52 @@ import {createSelectionActions, deletionStatus, isMultiple, isTyping, playheadId import {isolateDialogKeys, topDialog} from './dialog_keys.js'; import {createBlockNotes, describedTrack} from './editor_notes.js'; import {createFirstRunGuide} from './first_run_guide.js'; -const editorStyles = Promise.all(['editor.css', 'editor_tracks.css', 'editor_frame.css', 'editor_adjust.css', 'editor_adjust_picture.css', 'editor_preview_picture.css', 'editor_select.css', 'editor_menu.css', 'editor_text.css', 'editor_notes.css', 'editor_transport.css'].map(name => { +import {createEditorFailures} from './editor_failure.js'; +const editorStyles = Promise.all(['result_actions.css', 'editor.css', 'editor_tracks.css', 'editor_frame.css', 'editor_adjust.css', 'editor_adjust_picture.css', 'editor_preview_picture.css', 'editor_select.css', 'editor_menu.css', 'editor_text.css', 'editor_notes.css', 'editor_transport.css'].map(name => { const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = new URL(name, import.meta.url).href; const ready = 'sheet' in style ? new Promise(resolve => { style.onload = style.onerror = resolve; }) : Promise.resolve(); document.head.append(style); return ready; })); const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; const language = () => new URLSearchParams(location.search).get('freevideo_lang') || navigator.language; +// Shared by error() and actions.reasonText: one sentence naming the failed step. +export function editorErrorText(value, t = en => en) { + const oneLine = value => typeof value === 'string' ? value.trim().split(/[\r\n]+/).find(line => line.trim())?.trim() || '' : ''; + const detail = value?.detail || (value?.error && typeof value.error === 'object' ? value.error : value); + const raw = [detail?.response?.error?.message, detail?.response?.error, detail?.response?.message, + detail?.exception_message, detail?.message, value?.error?.message, value?.error, value].map(oneLine).find(Boolean) || ''; + const code = value?.code || detail?.code || value?.reason || detail?.reason || raw; + const node = oneLine(detail?.node_type), step = `${value?.phase || ''} ${value?.stage || ''} ${node}`; + const part = Number.isInteger(value?.index) ? value.index + 1 : null; + const generating = part ? t(`Generating part ${part}`, `生成第 ${part} 段`) : t('Generation', '生成'); + const stage = /encod|编码/i.test(step) ? t('Encoding', '编码') : /decod|解码/i.test(step) ? t('Decoding', '解码') + : /load|model|加载/i.test(step) ? t('Loading the model', '加载模型') : generating; + if (code === 'gpu_memory' || /out of memory|cuda.*memory|显存不足|显存不够/i.test(raw)) + return t(`${stage} ran out of GPU memory`, `${stage}时显存不足`); + const known = { + chain_part_too_short: t('Each continued part must be at least 10 seconds.', '续接的每一段至少要 10 秒。'), + chain_too_long: t('A video can have up to 5 parts.', '一条视频最多 5 段。'), + too_long: t('This part is longer than the maximum length. Shorten it and try again.', '这一段超过了最长时长,请缩短后再试。'), + missing_node: t('Generation is not ready. Restart FreeVideo.', '生成功能没有就绪,请重新启动 FreeVideo。'), + chain_missing: t('Generation could not be submitted. Choose "Generate" again.', '提交生成时出错,请再点一次“生成”。'), + result_mismatch: t('Reading the result failed. Choose "Generate" again.', '读取生成结果时出错,请再点一次“生成”。'), + no_material: t('The media this part uses is missing.', '找不到这一段用到的素材。'), + }; + if (known[code]) return known[code]; + if (['model_missing', 'missing_model', 'model_not_found'].includes(code) || /(?:model|checkpoint|模型).*(?:not found|missing|不存在|缺失)/i.test(raw)) + return t(`Loading the model failed: ${raw || code}`, `加载模型时出错:${raw || code}`); + if (['timeout', 'timed_out', 'generation_timeout'].includes(code) || /timed?\s*out|timeout|超时/i.test(raw)) + return t(`${stage} timed out`, `${stage}时等待超时`); + const received = raw || (/^[a-z_]+$/.test(code) ? '' : code); + return received ? t(`${generating} failed: ${received}`, `${generating}时出错:${received}`) + : t(`${generating} failed`, `${generating}时出错`); +} +function exportErrorText(value, t) { + const message = [value?.response?.error?.message, value?.response?.error, value?.response?.message, value?.message] + .find(text => typeof text === 'string' && text.trim() && text !== 'editor_request')?.trim().split(/[\r\n]+/)[0]; + // A bare status code means nothing to the user; the server's own words do. + return message ? t(`Export failed: ${message}`, `导出时出错:${message}`) : t('Export failed. Try again.', '导出时出错,请再试一次。'); +} function restoreGeneration(record, before, prepared) { const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); const entries = value => new Map(value.tracks.flatMap(track => track.clips.map(clip => [clip.id, {track: track.id, clip}]))); @@ -127,10 +166,11 @@ export function openEditor({api, app, from = null, panel = {}, components: given drawer.setAttribute('aria-expanded', 'false'); const exportBox = el('div', 'fv-ed-export-box'), exportHint = el('span', 'fv-ed-export-hint'), exportButton = button(t('Export', '导出'), async () => { try { + if (explainExport()) return; if (exportStatus?.state === 'done' && exportMatches()) { await actions.export(); return; } const options = await openExportDialog({parent: element, state, sources: store.sources, t, onError: error}); if (options && !disposed) await actions.export(options); - } catch (value) { error(value); } + } catch (value) { error(value, 'export'); } }, 'fv-primary'); const exportCancel = button('', () => store.cancelExport(exportStatus.id).catch(error)); exportCancel.hidden = true; exportCancel.append(icon('m4 4 8 8M12 4l-8 8')); exportCancel.setAttribute('aria-label', t('Cancel', '取消')); exportBox.append(exportHint, exportButton, exportCancel); brand.append(wordmark(), title); @@ -161,8 +201,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given let library = null, closing = false, closePromise = null, exportSnapshot = null, lastSeed = null, projectEpoch = 0; let disposed = false, suppressSave = false, savedContent = '', initializing = true, tracks = null, genbar = null, adjustPanel = null, exportStatus = null, pointerEditing = false, deferredStamp = null; let capability = {maxSeconds: 15, maxFollowSeconds: 15, maxRequestFrames: null, estimate: () => null}, generationBusy = false, switching = false, generationDone = null, finishGeneration = null; - let noticeTimer = null, stopExport = null, mostRecentJob = null, guide = null; + let noticeTimer = null, stopExport = null, mostRecentJob = null, exportPreparing = false, guide = null; const jobWatchers = new Map(), progress = new Map(), jobStatuses = new Map(), runProgress = new Map(); + const failures = createEditorFailures({api, state, jobStatuses, reasonText: value => editorErrorText(value, t)}); + let submissionFailure = null; let blockStates = null, progressObserver = null, progressConnection = null; function barJob(job) { const latest = [...runProgress.values()].filter(message => message.chain.id === job.id) @@ -190,21 +232,29 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (message.prompt_id && block.prompt_id && message.prompt_id !== block.prompt_id) return false; if (previous?.prompt_id === message.prompt_id && previous?.stream_id === message.stream_id && Number.isFinite(previous?.sequence) && previous.sequence >= message.sequence) return true; + failures.receive(job, block, message); runProgress.set(key, message); paintJob(job); + if (model.attemptClip(state.record, block.id)?.pending === 'failed') tracks?.refresh?.(); if (mostRecentJob === job.id) genbar?.update(barJob(job)); return true; } const content = record => JSON.stringify({...record, id: null, revision: 0}); function notify(text) { if (disposed) return; notice.textContent = text; notice.hidden = false; clearTimeout(noticeTimer); noticeTimer = setTimeout(() => { notice.hidden = true; }, 9000); } - function error(value) { - const code = value?.code || value?.message; + // `step` names what was being done, so a failure there says which step went wrong. + function error(value, step = null) { + const code = value?.reason || value?.code || value?.message; + if (['missing_glyphs', 'MissingGlyphs', 'source_missing'].includes(code)) { + notify(code === 'source_missing' ? t('A source file is missing. Import it again from the library.', '有素材的原文件找不到了,请在素材库重新导入。') + : t('The chosen font is missing some characters. Choose another font.', '所选字体缺少部分字符,请换一种字体。')); + return {ok: false, reason: code}; + } if (['audio_prepare', 'speed_pending', 'speed_locked', 'speed'].includes(code)) return {ok: false, reason: code}; if (code === 'unsupported') { genbar?.update(null); return; } if (['chain_too_long', 'chain_part_too_short'].includes(code)) { const [en, zh] = (value.said || []).map(text => typeof text === 'string' && text ? text.slice(0, 200) : null); - notify(code === 'chain_too_long' ? t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短一些。') - : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接着生成的每一段至少 10 秒,请加长一些。')); - return {ok: false, reason: code}; + const text = code === 'chain_too_long' ? t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短一些。') + : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接着生成的每一段至少 10 秒,请加长一些。'); + notify(text); return {ok: false, reason: code, say: text}; } if (['busy', 'locked', 'text', 'audio_full', 'overlap', 'range'].includes(code)) { if (['locked', 'audio_full', 'overlap', 'range'].includes(code)) tracks?.flashBlocked?.(value.displayFrame); @@ -214,7 +264,8 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (said) notify(said); return {ok: false, reason: code}; } - notify(t('Could not complete. Try again.', '没有完成,请重试')); + const text = step === 'generate' ? editorErrorText(value, t) : step === 'export' ? exportErrorText(value, t) : t('Could not complete. Try again.', '没有完成,请重试'); + notify(text); return {ok: false, reason: code, say: text}; } const store = createEditorStore(api, {t, notify, standIn: given?.generationStandIn, onGenerationReady: ready => { if (disposed) return; @@ -323,16 +374,19 @@ export function openEditor({api, app, from = null, panel = {}, components: given return found ? {text: found.text, before: false} : null; } const markHeld = (ids, queued) => { for (const id of ids) tracks?.setProgress(id, {state: 'waiting', fraction: 0, remainingSeconds: null, ...(queued ? {queued: true} : {})}); }; - function hold(selection, text) { + const queuedReady = () => t('Queued. It starts once generation is ready.', '已排队,准备好后自动开始。'); + function hold(selection, text, job = null) { const range = selectionRange(selection), selectedIds = selectionClips(selection).map(clip => clip.id), shown = selection === state.selection; let ids = []; - state.apply(t('Generate', '生成'), record => { + if (job) ids = selectedIds; + else state.apply(t('Generate', '生成'), record => { const selected = selectedIds.map(id => model.find(record, id)).filter(Boolean), planned = selected.length && selected.every(clip => !clip.source && clip.pending && !generating(clip)); const next = planned ? model.startAttempt(record, selected.map(clip => clip.id), text) : model.planRange(record, {...range, target: 'both', text, maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}); ids = next.tracks[0].clips.filter(clip => clip.start >= range.start && clip.start < range.start + range.frames && clip.pending && !generating(clip)).map(clip => clip.id); return next; }, {checkpoint: true}); - held.push({ids, text}); markHeld(ids, true); + if (!ids.every(isHeld)) held.push({ids, text, job}); + markHeld(ids, true); if (shown) state.select({kind: 'clip', ids}); rememberView(); } @@ -341,6 +395,14 @@ export function openEditor({api, app, from = null, panel = {}, components: given startingHeld = true; try { while (held.length && !disposed) { + if (generationBusy || !store.generationAvailable || closing) break; + if (held[0].job) { + const entry = held.shift(); markHeld(entry.ids, false); + if (entry.ids.some(id => model.find(state.record, id)?.job === entry.job)) { + try { await actions.resume(entry.job); } catch (value) { error(value, 'generate'); } + } + continue; + } const entry = held[0], clips = entry.ids.map(id => model.find(state.record, id)).filter(clip => clip && clip.pending && !generating(clip)); if (!clips.length) { held.shift(); markHeld(entry.ids, false); rememberView(); continue; } const selection = {kind: 'clip', ids: clips.map(clip => clip.id)}, before = partBefore(Math.min(...clips.map(clip => clip.start))); @@ -348,7 +410,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given held.shift(); markHeld(entry.ids, false); rememberView(); if (before && !before.source && before.pending) { genbar?.say(t('The part before did not finish, so this one did not start.', '上一段没有完成,这一段没有开始。')); continue; } try { await actions.generate({text: entry.text, selection}); } - catch (value) { const reason = error(value); if (reason && !['chain_too_long', 'chain_part_too_short'].includes(reason.reason)) genbar?.say(t('This part did not start. Choose "Generate" again.', '这一段没有开始,请再点一次“生成”。')); } + catch (value) { const reason = error(value, 'generate'); if (reason && !['chain_too_long', 'chain_part_too_short'].includes(reason.reason)) genbar?.say(reason.say); } } } finally { startingHeld = false; } } @@ -356,12 +418,38 @@ export function openEditor({api, app, from = null, panel = {}, components: given return store.getSources(model.references(state.record).map(sourceKey)).then(() => { player.refresh(); tracks?.refresh?.(); }); } const exportMatches = () => exportSnapshot && exportSnapshot.id === state.record.id && exportSnapshot.revision === state.record.revision && exportSnapshot.content === content(state.record); + function explainExport() { + if (initializing || switching) { notify(t('Opening the edit…', '正在打开…')); return true; } + if (exportPreparing || ['running', 'queued'].includes(exportStatus?.state)) { + notify(t('Exporting. You can export again when it finishes.', '正在导出,完成后可以再导出一次。')); + exportBox.scrollIntoView?.({block: 'nearest'}); exportButton.focus({preventScroll: true}); return true; + } + if (!model.exportFrames(state.record)) { notify(t('The timeline is empty. Add a clip first.', '时间线是空的,请先放入素材。')); return true; } + return false; + } + async function saveForExport(epoch) { + await queue.flush(); + for (let retry = 0; queue.dirty && retry < 3; retry++) { + await new Promise(resolve => setTimeout(resolve, 200)); + if (disposed || closing || epoch !== projectEpoch) return false; + await queue.flush(); + } + if (disposed || closing || epoch !== projectEpoch) return false; + if (queue.dirty) { + notify(t('Changes could not be saved after three tries. Check disk space, then export again.', '改动没有保存成功,已重试三次。请检查磁盘空间后再导出。')); + return false; + } + return !!state.record.id; + } function updateTop() { title.replaceChildren(el('span', '', state.record.name || new Date().toLocaleDateString()), icon('m4 6 4 4 4-4')); title.title = state.record.name; exportButton.classList.toggle('fv-primary', !!model.exportFrames(state.record) && !state.selection && !jobActive() && !exportStatus); - if (exportStatus?.state === 'done') exportButton.textContent = exportMatches() ? t('View', '查看') : t('Export', '导出'); - exportButton.disabled = initializing || !model.exportFrames(state.record) || switching || ['running', 'queued'].includes(exportStatus?.state); - exportButton.title = ''; + const running = exportPreparing || ['running', 'queued'].includes(exportStatus?.state); + if (['running', 'queued'].includes(exportStatus?.state)) exportButton.textContent = t(`Export ${Math.round((exportStatus.fraction || 0) * 100)}%`, `导出 ${Math.round((exportStatus.fraction || 0) * 100)}%`); + else if (exportPreparing) exportButton.textContent = queue.dirty ? t('Saving changes…', '正在保存改动…') : t('Exporting', '正在导出'); + else exportButton.textContent = exportStatus?.state === 'done' && exportMatches() ? t('View', '查看') : t('Export', '导出'); + // Never disabled: a press that cannot export says why next to it. + exportButton.disabled = false; exportButton.title = ''; } async function snapshot() { let value = panel.snapshot ? structuredClone(await panel.snapshot()) : structuredClone(await app.graphToPrompt()); @@ -386,6 +474,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const previous = jobStatuses.get(job.id); if (Number.isFinite(previous?.updated) && job.updated < previous.updated) return ['queued', 'running', 'cancelling'].includes(previous.state); + failures.reconcile(job, previous); mostRecentJob = job.id; jobStatuses.set(job.id, job); paintJob(job); const completed = new Set(); for (const block of job.blocks || []) { @@ -412,7 +501,8 @@ export function openEditor({api, app, from = null, panel = {}, components: given error(value); } } else if (block.state === 'failed') { - const reason = /^[a-z_]{1,80}$/.test(block.error || '') ? block.error : 'generation_failed'; + const cause = block.reason || block.error; + const reason = /^[a-z_]{1,80}$/.test(cause || '') ? cause : 'generation_failed'; state.settle(record => model.fail(record, block.id, reason, job.id)); } else if (['cancelled', 'interrupted'].includes(block.state) || (['cancelled', 'interrupted', 'failed'].includes(job.state) && block.state === 'pending')) { state.settle(record => pinStoppedJob(record, [block.id], job.id)); @@ -460,8 +550,8 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (!remembered) try { remembered = JSON.parse(localStorage.getItem(viewPrefix + viewKey)); } catch {} initialSelection = offerInitial = extendEntry = null; projectEpoch++; held.length = 0; - player.pause(); for (const stop of jobWatchers.values()) stop(); jobWatchers.clear(); jobStatuses.clear(); progress.clear(); runProgress.clear(); progressConnection?.reset(); - mostRecentJob = null; genbar?.update(null); queue.reset(record); stopExport?.(); stopExport = null; exportStatus = exportSnapshot = null; exportButton.textContent = t('Export', '导出'); + player.pause(); for (const stop of jobWatchers.values()) stop(); jobWatchers.clear(); jobStatuses.clear(); progress.clear(); runProgress.clear(); failures.clear(); progressConnection?.reset(); + mostRecentJob = null; submissionFailure = null; genbar?.update(null); queue.reset(record); stopExport?.(); stopExport = null; exportStatus = exportSnapshot = null; exportButton.textContent = t('Export', '导出'); suppressSave = true; try { state.replace(record); savedContent = content(record); } finally { suppressSave = false; } const width = tracksHost.clientWidth - 148; if (width > 0) state.setZoom(width / 90); tracks?.setScrollLeft(0); @@ -540,6 +630,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given } const actions = { error, editError: error, notify, sources: store.sources, waveform: key => store.waveform(key), mediaURL: value => mediaURL(api, value), + projectToken: () => initializing ? null : projectEpoch, togglePlay: () => player.toggle(), pause: () => player.pause(), async previewSource(source) { @@ -578,7 +669,19 @@ export function openEditor({api, app, from = null, panel = {}, components: given return {ok: false, reason: valueError.code}; } }, - reasonText: code => code === 'gpu_memory' ? t('Not enough graphics memory', '显存不够') : t('Could not generate', '没能生成'), + reasonText: value => editorErrorText(value, t), + failureText: id => failures.text(id), + selectedFailure() { + const clip = state.selection?.kind === 'clip' && state.record.tracks.flatMap(track => track.clips).find(clip => state.selection.ids.includes(clip.id) && clip.pending === 'failed'); + if (clip) return {id: clip.id, text: failures.text(clip.id)}; + return submissionFailure === JSON.stringify(state.selection) ? {id: 'submission', text: failures.text('submission')} : null; + }, + async downloadFailure(id) { + if (disposed || id !== 'submission' && !model.find(state.record, id)) return; + if (id !== 'submission') state.select({kind: 'clip', ids: [id]}); + await genbar.downloadFailure(actions.selectedFailure()?.id || id); + }, + downloadFailureReport: (id, options) => failures.download(id, options), supportedSelection(selection = state.selection) { if (selection?.kind === 'clip' && selection.ids.some(id => isText(model.find(state.record, id)))) return false; const range = selectionRange(selection); if (!range) return false; @@ -628,14 +731,15 @@ export function openEditor({api, app, from = null, panel = {}, components: given async generate({target = 'both', text = '', label = t('Generate', '生成'), selection = state.selection} = {}) { if (target !== 'both' || !actions.supportedSelection(selection)) throw new model.EditError('unsupported'); if (!actions.canGenerate(text, selection)) throw new model.EditError('text'); + submissionFailure = null; genbar?.update(null); const shown = selection === state.selection; generationBusy = true; updateTop(); updateTransport(); generationDone = new Promise(resolve => { finishGeneration = resolve; }); - let ids = [], prepared = null, beforeAttempt = null, priorSelection = null, priorText = null, submittedSelection = null; + let ids = [], prepared = null, beforeAttempt = null, priorSelection = null, priorText = null, submittedSelection = null, frozen = null; const epoch = projectEpoch, range = selectionRange(selection), selectedIds = selectionClips(selection).map(clip => clip.id); sendingRanges.add(range); try { - const frozen = await snapshot(); if (epoch !== projectEpoch || closing || disposed || !store.generationAvailable) return; + frozen = await snapshot(); if (epoch !== projectEpoch || closing || disposed || !store.generationAvailable) return; beforeAttempt = state.record; priorSelection = state.selection; priorText = state.rangeText; state.apply(label, record => { const selected = selectedIds.map(id => model.find(record, id)).filter(Boolean), alreadyPlanned = selected.length && selected.every(clip => !clip.source && clip.pending && !['queued', 'running'].includes(clip.pending)); @@ -682,9 +786,14 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (epoch !== projectEpoch) return; state.settle(record => model.assignJob(record, ids, job.id, {prepared})); queue.enqueue(state.record); + failures.remember(job, frozen); mostRecentJob = job.id; jobStatuses.set(job.id, job); genbar.update(job); watchJob(job.id); } catch (value) { - if (ids.length && epoch === projectEpoch) state.settle(record => model.settle(record, ids, 'draft')); + if (epoch === projectEpoch) { + if (ids.length) state.settle(record => model.settle(record, ids, 'draft')); + failures.submission(value, frozen, text); + submissionFailure = JSON.stringify(state.selection); genbar?.update(null); + } throw value; } finally { sendingRanges.delete(range); for (const id of ids) sending.delete(id); if (disposed && ids.length) queue.enqueue(state.record); generationBusy = false; finishGeneration?.(); generationDone = null; finishGeneration = null; updateTop(); updateTransport(); startHeld(); } }, @@ -713,7 +822,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const waiting = waitingFor(selection); if (waiting) { hold(selection, text); - return {text, say: [said, used, waiting === 'ready' ? t('Queued. It starts once generation is ready.', '已排队,准备好后自动开始。') + return {text, say: [said, used, waiting === 'ready' ? queuedReady() : t('Queued. It starts after the part before it.', '已排队,上一段完成后自动开始。')].filter(Boolean).join(' '), queued: true}; } const busyBefore = jobActive(); @@ -739,16 +848,28 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (!own && actions.selectionSending()) return; const id = own || mostRecentJob; if (id) await deliverChain(await store.chainAction(id, 'cancel')); }, - async resume() { - if (!store.generationAvailable) return; + async resume(jobId = null) { const epoch = projectEpoch; - const id = mostRecentJob || selectionClips().find(clip => clip.job)?.job; if (!id) return; + const id = jobId || mostRecentJob || selectionClips().find(clip => clip.job)?.job; if (!id) return; + if (!store.generationAvailable) { + const ids = state.record.tracks[0].clips.filter(clip => clip.job === id && clip.pending).map(clip => clip.id); + hold({kind: 'clip', ids}, '', id); genbar?.say(queuedReady()); + if (genHost.hidden) notify(queuedReady()); + void startHeld(); return; + } const job = await store.chainAction(id, 'resume'); if (epoch !== projectEpoch || disposed) return; for (const block of job?.blocks || []) if (block.state !== 'done') runProgress.delete(`${id}:${block.id}`); const ids = state.record.tracks[0].clips.filter(clip => clip.job === id && clip.pending).map(clip => clip.id); state.settle(record => model.settle(record, ids, 'queued')); if (job) { await deliverChain(job); watchJob(id); } }, - async retry(id) { if (!store.generationAvailable) return; state.select({kind: 'clip', ids: [id]}); const clip = model.find(state.record, id); if (clip.job) { mostRecentJob = clip.job; await actions.resume(); } else await actions.generate({text: clip.text || panel.text || ''}); }, + async retry(id) { + state.select({kind: 'clip', ids: [id]}); const clip = model.find(state.record, id); if (!clip) return; + if (clip.job) { mostRecentJob = clip.job; await actions.resume(); } + else if (!store.generationAvailable) { + if (!actions.selectionHeld()) hold(state.selection, clip.text || panel.text || ''); + genbar?.say(queuedReady()); if (genHost.hidden) notify(queuedReady()); void startHeld(); + } else await actions.generate({text: clip.text || panel.text || ''}); + }, // Subtitles from speech hear the saved project, so it is saved first; a project never saved has nothing in it to hear. speech: { async start() { @@ -782,25 +903,30 @@ export function openEditor({api, app, from = null, panel = {}, components: given cancelDownload: () => store.cancelEarsPack(), }, async export(options) { - if (['running', 'queued'].includes(exportStatus?.state)) return; + if (explainExport()) return; if (exportStatus?.state === 'done' && exportMatches()) { await viewExport(exportStatus); return; } - if (!await textTools.beforeExport()) return; - await queue.flush(); - if (queue.dirty) { return; } - if (!state.record.id) return; - exportSnapshot = {id: state.record.id, revision: state.record.revision, content: content(state.record)}; - for (let attempt = 0; ; attempt++) { - try { exportStatus = await store.export(state.record.id, state.record.revision, options); break; } - catch (value) { - if (value.status !== 409 || attempt >= 2) throw value; - queue.enqueue(state.record); await queue.flush(); - if (queue.dirty) return; - exportSnapshot = {id: state.record.id, revision: state.record.revision, content: content(state.record)}; + const epoch = projectEpoch; exportPreparing = true; updateTop(); + try { + if (!await textTools.beforeExport()) return; + if (!await saveForExport(epoch)) return; + exportSnapshot = {id: state.record.id, revision: state.record.revision, content: content(state.record)}; + for (let attempt = 0; ; attempt++) { + try { + const status = await store.export(state.record.id, state.record.revision, options); + if (disposed || closing || epoch !== projectEpoch) return; + exportStatus = status; break; + } + catch (value) { + if (value.status !== 409 || attempt >= 2) throw value; + queue.enqueue(state.record); + if (!await saveForExport(epoch)) return; + exportSnapshot = {id: state.record.id, revision: state.record.revision, content: content(state.record)}; + } } - } - if (!exportStatus) return; - updateExport(exportStatus); stopExport?.(); - stopExport = store.watchExport(exportStatus.id, updateExport); + if (!exportStatus) return; + updateExport(exportStatus); stopExport?.(); + stopExport = store.watchExport(exportStatus.id, updateExport); + } finally { exportPreparing = false; updateTop(); } }, }; async function viewExport(value) { @@ -818,10 +944,14 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (exportSnapshot) exportSnapshot = {...exportSnapshot, id: value.project_id, revision: value.revision}; exportStatus = value; const running = value.state === 'running' || value.state === 'queued'; if (disposed) { if (!running) window.FreeVideoNotifyTaskDone?.({kind: 'export', id: value.id, ok: value.state === 'done'}); return running; } - exportButton.textContent = running ? t(`Export ${Math.round(value.fraction * 100)}%`, `导出 ${Math.round(value.fraction * 100)}%`) : value.state === 'done' && exportMatches() ? t('View', '查看') : t('Export', '导出'); - exportCancel.hidden = !running; exportButton.disabled = running; exportButton.classList.remove('fv-primary'); + exportCancel.hidden = !running; updateTop(); exportButton.classList.remove('fv-primary'); if (!running) { - if (value.state === 'failed') notify(t('Export did not finish', '导出没有完成')); + if (value.state === 'failed') { + if (['source_missing', 'missing_glyphs', 'MissingGlyphs'].includes(value.reason || value.code)) error(value); + else notify(value.reason === 'disk_full' ? t('Export failed: the disk is full. Free some space, then export again.', '导出时出错:磁盘空间不够,空出空间后再导出。') + : value.reason === 'unreadable' ? t('Export failed: a source file cannot be read. Import it again from the library.', '导出时出错:有素材读不出来,请在素材库重新导入。') + : t('Export failed. Try again.', '导出时出错,请再试一次。')); + } window.FreeVideoNotifyTaskDone?.({kind: 'export', id: value.id, ok: value.state === 'done'}); } return running; @@ -839,8 +969,14 @@ export function openEditor({api, app, from = null, panel = {}, components: given control.classList.add('fv-ed-icon'); control.setAttribute('aria-label', label); return control; }; - const toolUndo = toolButton(t('Undo', '撤销'), 'M5 4 2 7l3 3M2 7h7a4 4 0 0 1 0 8', () => state.undo(), 'fv-ed-undo fv-ed-icon', true); - const toolRedo = toolButton(t('Redo', '重做'), 'm11 4 3 3-3 3M14 7H7a4 4 0 0 0 0 8', () => state.redo(), 'fv-ed-redo fv-ed-icon', true); + // Undo cannot take back a part while it generates; a press that changes nothing says so. + function travel(back) { + const before = content(state.record); back ? state.undo() : state.redo(); + if (content(state.record) === before && jobActive()) notify(back ? t('This part is generating. Undo once it finishes.', '这一段正在生成,完成后再撤销。') + : t('This part is generating. Redo once it finishes.', '这一段正在生成,完成后再重做。')); + } + const toolUndo = toolButton(t('Undo', '撤销'), 'M5 4 2 7l3 3M2 7h7a4 4 0 0 1 0 8', () => travel(true), 'fv-ed-undo fv-ed-icon', true); + const toolRedo = toolButton(t('Redo', '重做'), 'm11 4 3 3-3 3M14 7H7a4 4 0 0 0 0 8', () => travel(false), 'fv-ed-redo fv-ed-icon', true); const split = toolButton(t('Split', '剪开'), 'M6 5l8 8M6 11l8-8M6 4a2 2 0 1 1-4 0 2 2 0 0 1 4 0M6 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0', () => selectionActions.run('b'), 'fv-ed-split'); const remove = toolButton(t('Delete', '删除'), 'M2 4h12M6 4V2h4v2M4 4l1 10h6l1-10M7 6v5M9 6v5', @@ -962,7 +1098,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (handleSpeedKey(event)) return; if (key === ' ') { event.preventDefault(); player.toggle(); } else if (key === 'arrowleft' || key === 'arrowright') { event.preventDefault(); state.seek(Math.max(0, state.playhead + (key === 'arrowright' ? 1 : -1))); } - else if (mod && key === 'z') { event.preventDefault(); event.shiftKey ? state.redo() : state.undo(); } + else if (mod && key === 'z') { event.preventDefault(); travel(!event.shiftKey); } } listen('keydown', keyboard); listen('keyup', event => event.stopPropagation()); listen('cancel', event => { @@ -1040,7 +1176,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given listeners.splice(0).forEach(remove => remove()); for (const node of [element, ...element.querySelectorAll('*')]) for (const name of ['onclick', 'ondblclick', 'oninput', 'onchange', 'onkeydown', 'ondragstart']) node[name] = null; unsubscribe(); stopSources(); library?.dispose(); genbar.dispose(); tracks?.dispose(); stopExport?.(); - progressConnection?.stop(); progressObserver?.disconnect(); runProgress.clear(); + progressConnection?.stop(); progressObserver?.disconnect(); runProgress.clear(); failures.clear(); stopKeys(); resumeCanvasPointer(); window.removeEventListener('pagehide', leaving); clearTimeout(noticeTimer); @@ -1110,6 +1246,12 @@ export function openEditor({api, app, from = null, panel = {}, components: given transportControls.attachTracks(tracks, module.snapFrame); if (api.addEventListener && api.removeEventListener) { progressConnection = createProgressConnection(api, receiveProgress); progressConnection.start(); + const executionError = event => { + if (!failures.capture(event.detail)) return; + tracks?.refresh?.(); genbar?.update(mostRecentJob ? barJob(jobStatuses.get(mostRecentJob)) : null); + }; + api.addEventListener('execution_error', executionError); + listeners.push(() => api.removeEventListener('execution_error', executionError)); } if (panel.progressElement && typeof MutationObserver !== 'undefined') { progressObserver = new MutationObserver(() => { for (const job of jobStatuses.values()) paintJob(job); }); diff --git a/web/editor_failure.js b/web/editor_failure.js new file mode 100644 index 00000000..d778982b --- /dev/null +++ b/web/editor_failure.js @@ -0,0 +1,86 @@ +import {attemptClip, find} from './editor_model.js'; +import {createErrorReport} from './error_panel.js'; +import {saveReport, showReportTip, reportIssue} from './report_issue.js'; +import {productVersion} from './updates.js'; + +// Receipts belong to an attempt, not to saved projects or undo history. +export function createEditorFailures({api, state, jobStatuses, reasonText}) { + const details = new Map(), snapshots = new Map(), progress = new Map(); + let submissionError = null; + const owns = (job, block) => attemptClip(state.record, block.id)?.job === job.id; + const key = (job, block) => `${job.id}:${block.id}`; + function receipt(id) { + const clip = find(state.record, id) || (id === 'submission' && submissionError ? {id, text: submissionError.text} : null); + if (!clip) return null; + const job = jobStatuses.get(clip.job); + // Redo can restore the original clip (including its audio partner). + const owned = job?.blocks?.filter(block => owns(job, block)) || []; + const block = owned.find(block => block.id === clip.id || block.id === clip.link) + || owned.find(block => block.state === 'failed' && clip.part && block.part === clip.part); + const live = block && progress.get(key(job, block)); + const current = live && (!block.prompt_id || live.prompt_id === block.prompt_id) ? live : null; + const submission = id === 'submission' ? submissionError : null; + const detail = details.get(block?.prompt_id || current?.prompt_id) || submission?.detail; + const snapshot = snapshots.get(clip.job) || submission?.snapshot, index = job?.blocks?.indexOf(block); + const value = {reason: block?.reason || current?.reason || clip.reason, + error: block?.error || current?.error, detail, + phase: current?.phase, stage: current?.stage, index: index >= 0 ? index : null}; + return {clip, job, block, snapshot, value, text: reasonText(value), attempt: `${clip.job || 'submission'}:${block?.prompt_id || block?.attempts || 0}`, + reportId: [current?.report_id, block?.report_id].find(id => /^[a-f0-9]{32}$/.test(id || ''))}; + } + function report(id) { + const found = receipt(id); if (!found) return null; + const {clip, job, block, snapshot, value} = found; + const nodeId = snapshot?.node || job?.node || 'editor', original = snapshot?.prompt?.[nodeId]; + const detail = value.detail || (block?.error && typeof block.error === 'object' ? block.error : null) || {exception_message: block?.error || found.text, + node_id: nodeId, node_type: original?.class_type || 'FreeVideoGenerate'}; + const inputs = {...original?.inputs, ...detail.current_inputs, ...block?.inputs, + text: block?.inputs?.text ?? clip.text}; + return createErrorReport(detail, {stage: id === 'submission' ? 'submission' : 'execution', node: {id: nodeId}, + snapshot: {output: {...snapshot?.prompt, [nodeId]: {...original, inputs}}}, + progress: {phase: value.phase || clip.id, stage: value.stage || (value.index != null ? `Part ${value.index + 1}` : ''), + ...(job ? {done: job.blocks.filter(block => block.state === 'done').length, total: job.blocks.length} : {})}}); + } + return { + remember(job, snapshot) { snapshots.set(job.id, snapshot); }, + reconcile(job, previous) { + for (const block of job.blocks || []) { + const old = previous?.blocks?.find(value => value.id === block.id), live = progress.get(key(job, block)); + if (old && (old.prompt_id !== block.prompt_id || old.attempts !== block.attempts) + && live?.prompt_id === old.prompt_id) progress.delete(key(job, block)); + } + }, + submission(error, snapshot, text) { submissionError = {detail: error, snapshot, text}; }, + capture(detail) { + if (!detail?.prompt_id || typeof detail.prompt_id !== 'string') return false; + for (const job of jobStatuses.values()) { + if (!job.blocks?.some(block => owns(job, block) && (block.prompt_id === detail.prompt_id + || !block.prompt_id && progress.get(key(job, block))?.prompt_id === detail.prompt_id))) continue; + details.set(detail.prompt_id, structuredClone(detail)); return true; + } + return false; + }, + receive(job, block, message) { + const previous = progress.get(key(job, block)); + progress.set(key(job, block), {...(previous?.prompt_id === message.prompt_id ? previous : {}), ...message}); + }, + text(id) { return receipt(id)?.text || ''; }, + report, + async download(id, {bar, anchor, t, current = () => true}) { + const found = receipt(id); if (!found) return; + let blob; + if (found.reportId) { + const response = await api.fetchApi(`/freevideo/report/${found.reportId}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + blob = await response.blob(); + } else blob = new Blob([JSON.stringify(report(id), null, 2)], {type: 'application/json'}); + const latest = receipt(id); + if (!current() || latest?.attempt !== found.attempt || latest?.value.detail !== found.value.detail) return; + saveReport(blob); + // Use exactly the video's card and submit flow, including no second download. + return showReportTip(bar, anchor, t, {mode: 'downloaded', + submit: () => reportIssue(() => Promise.resolve(blob), productVersion(), {save: false})}); + }, + clear() { details.clear(); snapshots.clear(); progress.clear(); submissionError = null; }, + }; +} diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 0a07aefa..009c856a 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -18,6 +18,11 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { host.append(hint, note, input, generate, cancel, resume); const firstFrame = createFirstFrameSwitch(host, {state, t, actions}); input.after(firstFrame.element); + const failure = el('div', 'fv-ed-gen-failure fv-result-links'), failureBar = el('div', 'fv-ed-failure-actions fv-actions'); + const failureReason = el('span', 'fv-ed-failure-reason'); failureReason.setAttribute('role', 'status'); + const report = el('button', 'fv-ed-failure-report fv-action fv-action-secondary', t('Download diagnostic report', '下载诊断报告')); report.type = 'button'; + failureBar.append(failureReason, report); failure.append(failureBar); failure.hidden = true; host.append(failure); + let failureId = null, downloading = false; // Reference pictures for a part that starts fresh: thumbnails and a small "+" before the description. const references = actions.references ? createEditorReferences({t, source: actions.references.source, size: actions.references.size, error: actions.error, notify: actions.references.notify}) : null; if (references) input.before(references.element); @@ -25,6 +30,18 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { // Which parts are generating, or how the job ended: a "Queued" line goes once this moves on. const progressKey = () => ['done', 'failed', 'cancelled', 'interrupted'].includes(status?.state) ? status.state : (status?.blocks || []).filter(block => block.state === 'running').map(block => block.id).join(','); + async function downloadFailure(id = failureId) { + if (disposed || downloading || id !== failureId) return; + downloading = true; report.disabled = true; + try { + // A failed part offers its report on the part itself; the submit tip then hangs under this line's sentence. + await actions.downloadFailureReport?.(id, {bar: failureBar, anchor: report.hidden ? failureReason : report, t, + current: () => !disposed && failureId === id && !failure.hidden}); + } catch (value) { + say(t(`Downloading the diagnostic report failed: ${value.message || value}`, `下载诊断报告时出错:${value.message || value}`)); + } finally { downloading = false; report.disabled = false; } + } + report.onclick = () => downloadFailure(); function say(text, queued = false) { if (disposed) return; queuedAt = text && queued ? progressKey() : null; @@ -35,6 +52,15 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { if (disposed) return; if (queuedAt != null && progressKey() !== queuedAt) say(''); const current = ++serial, selection = previewSelection || state.selection; + const selectedFailure = !previewSelection && actions.selectedFailure?.(); + if (failureId !== (selectedFailure?.id || null)) { + failure.querySelector('.fv-report-tip')?.remove(); + failureId = selectedFailure?.id || null; + } + failure.hidden = !selectedFailure; + failureReason.textContent = selectedFailure?.text || ''; + // Only a press that never became a part has no part to carry the report button. + report.hidden = selectedFailure?.id !== 'submission'; const running = ['queued', 'running', 'cancelling'].includes(status?.state); const selected = !!selection && (!!previewSelection || (actions.supportedSelection?.() ?? true)); // While a part generates, choosing another place offers Generate again; it is queued behind. @@ -98,12 +124,13 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { if (result?.say) say(result.say, !!result.queued); if (result?.focus) { pressing.delete(key); render(); input.disabled = false; input.focus(); } } - // A specific reason has already been said; a general one is added only when there was none. - catch (error) { if (!actions.error(error) && !note.textContent) say(t('Could not start. Try again.', '没能开始,请重试。')); } + // A specific reason is said once; the planning limits are already shown above. + catch (error) { const result = actions.error(error, 'generate'); if (result?.say && !['chain_too_long', 'chain_part_too_short'].includes(result.reason)) say(result.say); + else if (!result && !note.textContent) say(t('Could not start. Try again.', '没能开始,请重试。')); } finally { pressing.delete(key); render(); } }; cancel.onclick = () => (!previewSelection && actions.selectionHeld?.() ? Promise.resolve(actions.unhold()).then(render) : actions.cancel()).catch(actions.error); - resume.onclick = () => actions.resume().catch(actions.error); + resume.onclick = () => actions.resume().catch(value => actions.error(value, 'generate')); const unsubscribe = state.subscribe(event => { if (event.type === 'selection' && state.selection?.kind === 'range') { if (state.rangeText === null) state.setRangeText(input.value); @@ -117,7 +144,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { if (event.type === 'selection' || event.type === 'record') render(); }); render(); - return {element: host, say, getText() { return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, + return {element: host, say, downloadFailure, getText() { return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, dispose() { disposed = true; ++serial; clearTimeout(noteTimer); unsubscribe(); references?.dispose(); }}; } diff --git a/web/editor_library.js b/web/editor_library.js index 3b52db32..3d185409 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -19,7 +19,9 @@ export function createEditorLibrary(host, {store, t, actions, state}) { let tab = 'work', rows = [], imported = [], next = null, disposed = false, loading = false; let listRead = Promise.resolve(), refreshTask = null, retryTimer = null, finishRetry = null, refreshAgain = false; const expectedOutputs = new Set(); - const uploading = new Map(), recentImports = new Map(); + const uploading = new Map(), recentImports = new Map(), pendingAdds = new Map(); + const preparing = () => t('Preparing this clip. It goes on the timeline when ready.', '素材正在准备,准备好后会自动放到时间线上。'); + const cannotOpen = () => t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; function metadata(row, kind) { const g = row.geometry || row, cached = store.sources.get(`${kind}:${row.id}`); return {frames: cached?.frames || Math.round(g.frames || (g.seconds || 0) * (g.fps || 24)), hasVideo: cached?.has_video ?? (kind === 'work' || row.kind === 'video'), @@ -27,22 +29,42 @@ export function createEditorLibrary(host, {store, t, actions, state}) { } // The "+" puts a video after the last picture and a sound at the play position; // a double click leaves the place to the page, which appends. - async function add(row, kind, plus = false) { + async function add(row, kind, plus = false, {queued = false, project = actions.projectToken?.()} = {}) { const source = {kind, id: row.id}, key = sourceKey(source), playhead = state?.playhead; await store.getSources([key]); - if (disposed) return; + if (disposed || project != null && project !== actions.projectToken?.()) return; const value = store.sources.get(key), fallback = metadata(row, kind), hasVideo = value?.has_video ?? fallback.hasVideo; const pictures = state?.record.tracks.find(track => track.id === 'v1')?.clips || []; const at = !plus || !state ? undefined : hasVideo ? Math.max(0, ...pictures.map(clip => clip.start + clip.frames)) : playhead; - await actions.insertSource({source, at, frames: value?.frames || fallback.frames, + const result = await actions.insertSource({source, at, frames: value?.frames || fallback.frames, hasVideo, hasAudio: value?.has_audio ?? fallback.hasAudio}); + if (queued && !disposed && result?.ok !== false) actions.notify?.(t('Added to the timeline.', '已放到时间线上。')); + } + function requestAdd(row, kind, plus = false) { + if (disposed) return; + if (kind === 'work' || row.state === 'ready') return add(row, kind, plus).catch(actions.error); + if (row.state === 'failed') { actions.notify?.(cannotOpen()); return; } + const key = `${kind}:${row.id}`; + if (!pendingAdds.has(key)) pendingAdds.set(key, {plus, project: actions.projectToken?.()}); + actions.notify?.(preparing()); + } + function startPending(items) { + for (const row of items) { + const key = `import:${row.id}`, pending = pendingAdds.get(key); + if (!pending) continue; + if (pending.project != null && pending.project !== actions.projectToken?.()) { pendingAdds.delete(key); continue; } + if (row.state === 'ready') { + pendingAdds.delete(key); + add(row, 'import', pending.plus, {...pending, queued: true}).catch(actions.error); + } else if (row.state === 'failed') { pendingAdds.delete(key); actions.notify?.(cannotOpen()); } + } } function card(row, kind) { const container = el('div', 'fv-ed-source-card'); const button = el('button', 'fv-ed-source'); button.type = 'button'; button.dataset.key = `${kind}:${row.id}`; const ready = kind === 'work' || row.state === 'ready'; const label = row.name || row.text || new Date(row.created_at).toLocaleDateString(); - button.setAttribute('aria-label', label); button.draggable = ready; button.disabled = !ready; + button.setAttribute('aria-label', label); button.draggable = ready; button.disabled = false; const picture = el('div', 'fv-ed-source-picture'); const url = kind === 'work' ? (store.sources.get(`work:${row.id}`)?.thumbnail || '/freevideo/library/thumbnail?' + new URLSearchParams({id: row.id})) : row.thumbnail; @@ -51,11 +73,12 @@ export function createEditorLibrary(host, {store, t, actions, state}) { if (ready) picture.append(el('span', 'fv-ed-source-time', stamp(metadata(row, kind).frames))); else if (row.state === 'failed') { picture.append(el('span', 'fv-ed-source-preparing', t('Cannot open', '打不开'))); - } else picture.append(el('span', 'fv-ed-source-preparing', `${Math.round((row.fraction || 0) * 100)}%`)); + } else picture.append(el('span', 'fv-ed-source-preparing', t('Preparing', '准备中') + + (Number.isFinite(row.fraction) ? ` ${Math.round(Math.max(0, Math.min(1, row.fraction)) * 100)}%` : ''))); button.append(picture); const used = el('span', 'fv-ed-source-used'); used.append(editorIcon('check')); used.setAttribute('aria-label', t('Used', '已用')); used.hidden = true; picture.append(used); - button.ondblclick = () => add(row, kind).catch(actions.error); - button.onkeydown = event => { if (event.key === 'Enter') { event.preventDefault(); add(row, kind).catch(actions.error); } }; + button.ondblclick = () => requestAdd(row, kind); + button.onkeydown = event => { if (event.key === 'Enter') { event.preventDefault(); requestAdd(row, kind); } }; button.ondragstart = event => { if (!ready) { event.preventDefault(); return; } const value = metadata(row, kind), key = `${kind}:${row.id}`; @@ -64,10 +87,10 @@ export function createEditorLibrary(host, {store, t, actions, state}) { store.getSources([key]).catch(actions.error); }; // Single click previews the source; double click also inserts it. - button.onclick = () => { if (ready) Promise.resolve().then(() => actions.previewSource?.({kind, id: row.id})).catch(actions.error); }; - const plus = el('button', 'fv-ed-source-add', '+'); plus.type = 'button'; plus.disabled = !ready; + button.onclick = () => { if (ready) Promise.resolve().then(() => actions.previewSource?.({kind, id: row.id})).catch(actions.error); else requestAdd(row, kind); }; + const plus = el('button', 'fv-ed-source-add', '+'); plus.type = 'button'; plus.disabled = button.disabled; plus.title = button.title; plus.setAttribute('aria-label', `${t('Add to the tracks', '加到轨道上')}: ${label}`); - const insert = event => { event.preventDefault(); event.stopPropagation(); if (ready && !(event.detail > 1)) add(row, kind, true).catch(actions.error); }; + const insert = event => { event.preventDefault(); event.stopPropagation(); if (!(event.detail > 1)) requestAdd(row, kind, true); }; plus.onclick = insert; plus.ondblclick = event => { event.preventDefault(); event.stopPropagation(); }; plus.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') insert(event); }; @@ -190,12 +213,16 @@ export function createEditorLibrary(host, {store, t, actions, state}) { try { const item = await store.upload(value, fraction => { row.fraction = fraction; render(); }); if (item && !disposed) { + const pending = pendingAdds.get(`import:${id}`); + pendingAdds.delete(`import:${id}`); + if (pending) pendingAdds.set(`import:${item.id}`, pending); recentImports.set(item.id, item); imported = [item, ...imported.filter(existing => existing.id !== item.id)]; if (place) { placing.push({id: item.id, place}); placeReady(imported); } + else startPending([item]); } - } catch { actions.notify(t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS); } - finally { uploading.delete(id); render(); } + } catch { actions.notify(cannotOpen()); } + finally { pendingAdds.delete(`import:${id}`); uploading.delete(id); render(); } } } file.onchange = () => { importFiles([...file.files]); file.value = ''; }; @@ -203,9 +230,9 @@ export function createEditorLibrary(host, {store, t, actions, state}) { for (const item of items) recentImports.delete(item.id); // A list request started before an upload may finish after it. imported = [...recentImports.values(), ...items]; render(); - placeReady(imported); + placeReady(imported); startPending(imported); }); const stopUsed = state?.subscribe(event => { if (event.type === 'record') updateUsed(); }); render(); load(); - return {element: host, importFiles, refresh, dispose() { disposed = true; host.removeEventListener('scroll', scheduleMore); resize.disconnect(); cancelAnimationFrame(moreFrame); stop(); stopUsed?.(); clearTimeout(retryTimer); finishRetry?.(); }}; + return {element: host, importFiles, refresh, dispose() { disposed = true; pendingAdds.clear(); host.removeEventListener('scroll', scheduleMore); resize.disconnect(); cancelAnimationFrame(moreFrame); stop(); stopUsed?.(); clearTimeout(retryTimer); finishRetry?.(); }}; } diff --git a/web/editor_store.js b/web/editor_store.js index 85923825..ea90d2cd 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -417,12 +417,13 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener notice('generate', 'Restart FreeVideo to generate.', '请重新启动 FreeVideo 后再生成。'); return null; } - if (response.status === 400) { + if (!response.ok) { // The server's own sentence for a video with too many parts, or a continued part too short, is shown as it is. const body = await response.json().catch(() => null); if (['chain_too_long', 'chain_part_too_short'].includes(body?.code)) throw Object.assign(new Error(body.code), {code: body.code, said: [body.error, body.error_zh]}); + throw Object.assign(new Error(typeof body?.error === 'string' ? body.error : body?.message || `HTTP ${response.status}`), + {status: response.status, reason: body?.reason || body?.code, response: body}); } - if (!response.ok) throw errorFor(response); return (await response.json())?.chain || null; }, async chain(id) { return standIn?.owns(id) ? standIn.chain(id) : (await json(chainRoute(id)))?.chain || null; }, @@ -433,8 +434,11 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener return (await json(chainRoute(id, action), post(payload)))?.chain || null; }, async export(id, revision, options) { - const value = await json('/freevideo/editor/export', post({id, revision, ...(options && Object.keys(options).length ? {options} : {})})); - if (!value) notice('export', 'Export is unavailable until you restart after updating.', '更新后重启即可导出。'); + const response = await request('/freevideo/editor/export', post({id, revision, ...(options && Object.keys(options).length ? {options} : {})})); + const value = await response.json().catch(() => null); + if (!response.ok && (response.status !== 404 || value?.reason || value?.code)) + throw Object.assign(errorFor(response), {reason: value?.reason, code: value?.code, items: value?.items}); + if (response.status === 404 || !value) { notice('export', 'To export, update FreeVideo and restart it.', '请更新 FreeVideo 并重新启动后再导出。'); return null; } return value?.export || null; }, watchExport(id, fn) { return watch(async () => (await json('/freevideo/editor/export?' + new URLSearchParams({id})))?.export || null, fn); }, diff --git a/web/editor_tracks.css b/web/editor_tracks.css index bbd6ece1..a816122d 100644 --- a/web/editor_tracks.css +++ b/web/editor_tracks.css @@ -39,6 +39,7 @@ .fv-ed-clip[data-state=failed]{border:1px solid color-mix(in srgb,var(--fv-warning) 60%,transparent);background:color-mix(in srgb,var(--fv-warning) 12%,transparent);cursor:grab} .fv-ed-clip-status{position:absolute;left:10px;right:4px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;overflow:hidden;color:var(--fv-warning);pointer-events:none} .fv-ed-clip-status svg{width:12px;height:12px;flex:none;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round} +.fv-ed-clip-failure-reason{overflow:hidden;text-overflow:ellipsis} .fv-ed-clip[data-busy=true]{cursor:default} .fv-ed-trim-handle{position:absolute;top:50%;width:4px;height:20px;border-radius:2px;transform:translateY(-50%);background:var(--fv-accent);cursor:ew-resize;z-index:2;opacity:0;pointer-events:auto} .fv-ed-trim-start{left:-2px} @@ -66,9 +67,11 @@ .fv-ed-zoom-slider{width:64px;height:24px;margin:0;accent-color:var(--fv-accent);cursor:pointer} .fv-ed-track-zoom .fv-ed-fit{padding:0 7px;white-space:nowrap;font-size:12px;margin-left:4px} .fv-ed-drag-label{position:absolute;z-index:9;padding:4px 7px;background:var(--fv-card);color:var(--fv-ink);border:1px solid var(--fv-border);border-radius:6px;font-size:12px;white-space:pre;line-height:1.5;pointer-events:none} -.fv-ed-clip-popover{position:absolute;z-index:10;width:240px;max-width:calc(100% - 16px);padding:10px;border:1px solid var(--fv-border);border-radius:8px;background:var(--fv-card)} +.fv-ed-clip-popover{position:absolute;z-index:10;width:280px;max-width:calc(100% - 16px);padding:10px;border:1px solid var(--fv-border);border-radius:8px;background:var(--fv-card)} .fv-ed-clip-popover p{margin:0 0 8px;font-size:13px} .fv-ed-clip-popover button{min-height:32px;padding:4px 10px;border:1px solid var(--fv-border);border-radius:8px;background:var(--fv-raised);cursor:pointer} +.fv-ed-clip-popover button+button{margin-left:6px} +.fv-ed-clip-popover{max-height:calc(100% - 16px);overflow:auto} .fv-ed-track-view [hidden]{display:none!important} @media(max-width:520px){.fv-ed-track-view{--fv-ed-head-width:32px}.fv-ed-track-head span{display:none}.fv-ed-track-zoom{right:8px}.fv-ed-clip-status{left:5px;font-size:11px}} @media(forced-colors:active){.fv-ed-clip,.fv-ed-selection,.fv-ed-gesture-preview{border-color:Highlight}.fv-ed-playhead,.fv-ed-snap-line{background:CanvasText}} diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 0ca06880..2ee0920f 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -163,6 +163,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const tooltip = create('div', 'fv-ed-drag-label'); tooltip.hidden = true; tooltip.setAttribute('role', 'status'); const popover = create('div', 'fv-ed-clip-popover'); popover.hidden = true; + let failedPopoverId = null; const zoomBar = create('div', 'fv-ed-track-zoom fv-ed-zoom'); const zoomOut = create('button', '', '−'), zoomIn = create('button', '', '+'); zoomOut.type = zoomIn.type = 'button'; @@ -443,6 +444,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = item.component?.update(value); } function renderRecord() { + const openFailure = !popover.hidden && failedPopoverId; selectGestures.finish(null, true); cachedPlan = ''; popover.hidden = true; const record = state.record, retained = new Set(), tracks = displayTracks(record); @@ -496,10 +498,11 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = && (right.pending === null || model.continuesBefore(state.record, right.id)); item.element.classList.toggle('fv-ed-joined-left', attached(track.clips[index - 1], clip)); item.element.classList.toggle('fv-ed-joined-right', attached(clip, track.clips[index + 1])); - if (visual === 'failed' && track.kind === 'video') item.status.replaceChildren(icon('warning')); + const failureText = visual === 'failed' ? actions.failureText?.(clip.id) || actions.reasonText?.(clip.reason) || clip.reason || t('Generation failed', '生成时出错') : ''; + if (visual === 'failed') item.status.replaceChildren(icon('warning'), create('span', 'fv-ed-clip-failure-reason', failureText)); else item.status.replaceChildren(); const status = {planned: t('Not generated yet', '还没生成'), running: t('Generating', '正在生成'), - done: t('Complete', '完成'), failed: t('Unsuccessful', '没成功')}[visual]; + done: t('Complete', '完成'), failed: failureText}[visual]; const seconds = Math.round(clip.frames / model.FPS); item.element.title = ''; item.element.setAttribute('aria-label', t(`${name}, clip ${index + 1}, ${seconds} s, ${status}`, @@ -526,6 +529,8 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = addAudio.setAttribute('aria-label', addAudio.textContent); content.style.height = `${top - 6}px`; playhead.style.height = `${top - 24}px`; layout(); + const failure = openFailure && clips.get(openFailure); + if (failure && model.clipState(failure.clip) === 'failed' && state.selection?.ids?.includes(openFailure)) failedPopover(failure); if (gesture?.type === 'move' && pointerEvent) updatePointer(pointerEvent); } function showGuide(frame) { guide.hidden = frame == null; if (frame != null) guide.style.left = `${pixels(frame)}px`; } @@ -563,14 +568,18 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = renderSelection(); } function failedPopover(item) { - const reason = create('p', '', actions.reasonText?.(item.clip.reason) || t('Could not generate', '没能生成')); + failedPopoverId = item.clip.id; + const reason = create('p', '', actions.failureText?.(item.clip.id) || actions.reasonText?.(item.clip.reason) || item.clip.reason || t('Generation failed', '生成时出错')); + const report = create('button', 'fv-ed-clip-report', t('Download diagnostic report', '下载诊断报告')); report.type = 'button'; + report.onclick = event => { event.stopPropagation(); popover.hidden = true; + Promise.resolve(actions.downloadFailure?.(item.clip.id)).catch(error => actions.editError?.(error)); }; const retry = create('button', '', t('Retry', '重试')); retry.type = 'button'; retry.onclick = () => { popover.hidden = true; - Promise.resolve().then(() => actions.retry?.(item.clip.id)).catch(error => actions.editError?.(error)); }; - popover.replaceChildren(reason, retry); popover.hidden = false; + Promise.resolve().then(() => actions.retry?.(item.clip.id)).catch(error => actions.editError?.(error, 'generate')); }; + popover.replaceChildren(reason, report, retry); popover.hidden = false; const bounds = element.getBoundingClientRect(), clipBounds = item.element.getBoundingClientRect(); - popover.style.left = `${clamp(clipBounds.left - bounds.left, 8, Math.max(8, bounds.width - 248))}px`; - popover.style.top = `${Math.min(clipBounds.bottom - bounds.top + 4, bounds.height - 96)}px`; + popover.style.left = `${clamp(clipBounds.left - bounds.left, 8, Math.max(8, bounds.width - 288))}px`; + popover.style.top = `${Math.max(8, Math.min(clipBounds.bottom - bounds.top + 4, bounds.height - popover.getBoundingClientRect().height - 8))}px`; } function clickSelection(event) { if (event.range) return; diff --git a/web/health.js b/web/health.js index 03987742..8e04ae56 100644 --- a/web/health.js +++ b/web/health.js @@ -14,7 +14,7 @@ const t = (en, zh) => cn ? zh : en; const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', - 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', + 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'report_issue.js', 'studio_recent.js', 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', From d74eea721e234a4640c1ed138e9ba6c7f535d094 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 08:58:47 +0000 Subject: [PATCH 12/53] Image generation explains why an empty description does not start a picture --- web/image_editor.css | 6 ++--- web/image_generation.js | 49 ++++++++++++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/web/image_editor.css b/web/image_editor.css index 86d84be3..a1084a90 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -11,7 +11,7 @@ .fv-image-editor [hidden]{display:none!important} .fv-image-editor :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px} .fv-image-editor button{font:inherit;color:var(--fv-ink);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent} -.fv-image-editor button:disabled{opacity:.38;cursor:default} +.fv-image-editor button:disabled,.fv-image-editor button[aria-disabled=true]{opacity:.38;cursor:default} .fv-image-editor input,.fv-image-editor select,.fv-image-editor textarea{font:inherit;color:var(--fv-ink)} /* Header: name and size, zoom in the middle, history and leave on the right. */ @@ -29,9 +29,9 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} .fv-ie-button{min-height:var(--fv-h-sm);padding:5px 12px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-raised);font-weight:var(--fv-medium);white-space:nowrap} -.fv-ie-button:hover:not(:disabled){background:var(--fv-hover)} +.fv-ie-button:hover:not(:disabled):not([aria-disabled=true]){background:var(--fv-hover)} .fv-ie-primary{background:var(--fv-accent);border-color:var(--fv-accent);color:#08131f;min-width:84px} -.fv-ie-primary:hover:not(:disabled){background:var(--fv-accent-hover)} +.fv-ie-primary:hover:not(:disabled):not([aria-disabled=true]){background:var(--fv-accent-hover)} .fv-ie-danger{color:var(--fv-danger);border-color:color-mix(in srgb,var(--fv-danger) 60%,transparent)} .fv-ie-quiet{background:transparent} .fv-ie-active{background:var(--fv-selected)!important;border-color:var(--fv-accent)!important;color:var(--fv-accent)!important} diff --git a/web/image_generation.js b/web/image_generation.js index 52f7aa20..15b91435 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -279,6 +279,7 @@ export function createImageGenSession({client, prepare, apply, onChange = () => export function createImageGeneration({client, renderer, t, getDoc, getSelection, commit, el, button, segmented, library = [], resolve = file => file, icon = () => '', onRatioChange}) { let mode = 'edit', prompt = '', mounted = null, references = []; + let promptRequired = false, attentionTimer = null; const copies = new Map(); // A reference is sent no larger than the largest picture the service makes. async function referencePNG(file) { @@ -304,7 +305,27 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection }); const limit = () => referenceLimit(session.state().info); const chosen = () => mode === 'edit' ? references.slice(0, limit() - 1) : []; - const start = () => session.start({mode, prompt, references: chosen()}); + function clearAttention() { + clearTimeout(attentionTimer); attentionTimer = null; + mounted?.input.classList.remove('fv-ie-active'); + } + function start() { + // The button is only marked unavailable, so a click while the service is being checked is turned away here. + const now = session.state(); + if (now.checking || !now.info?.installed) return; + if (!prompt.trim()) { + promptRequired = true; update(); + if (mounted) { + const {input} = mounted; + clearAttention(); input.focus(); input.classList.add('fv-ie-active'); + attentionTimer = setTimeout(() => { + input.classList.remove('fv-ie-active'); attentionTimer = null; + }, 300); + } + return; + } + return session.start({mode, prompt, references: chosen()}); + } function drawEdited(canvas) { try { const doc = getDoc(), layer = generationContext(doc, null, 'edit').layer; @@ -370,12 +391,16 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection input.setAttribute('aria-label', input.placeholder); action.textContent = state.running ? t('Stop', '停止') : t('Generate', '生成'); action.classList.toggle('fv-ie-primary', !state.running); - action.disabled = !state.running && (disabled || !prompt.trim()); action.hidden = !!missing; + action.setAttribute('aria-disabled', String(!state.running && (disabled || !prompt.trim()))); + action.title = !state.running && !prompt.trim() ? t('Write a short description first', '请先写一句画面描述') : ''; + action.hidden = !!missing; const percent = Math.floor(state.fraction * 100) + '%'; - note.textContent = missing ? imagePackUnavailableMessage(state.info, t) : state.error ? imageGenError(state.error, state.info, t) + note.textContent = missing ? imagePackUnavailableMessage(state.info, t) + : promptRequired ? t('Write a short description first.', '请先写一句画面描述。') + : state.error ? imageGenError(state.error, state.info, t) : state.running ? (state.stage === 'load' ? t('Loading the model', '加载模型') + ' ' : '') + percent : ''; note.hidden = !note.textContent; - bar.dataset.running = String(state.running); bar.dataset.message = String(!!missing || !!state.error); + bar.dataset.running = String(state.running); bar.dataset.message = String(!!missing || promptRequired || !!state.error); meter.hidden = !state.running; meter.setAttribute('aria-valuenow', String(Math.floor(state.fraction * 100))); fill.style.width = state.fraction * 100 + '%'; } @@ -412,8 +437,15 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection if (ratio) bar.append(ratio); bar.append(field, note, action, meter); mounted = {bar, input, action, note, meter, fill, group, refs, ratio}; - const grow = () => { input.style.height = '0px'; input.style.height = Math.min(80, Math.max(32, input.scrollHeight + 2)) + 'px'; }; - input.oninput = () => { prompt = input.value; session.clearError(); grow(); }; + const grow = () => { + input.style.height = '0px'; + input.style.height = (prompt.trim() ? Math.min(80, Math.max(32, input.scrollHeight + 2)) : 32) + 'px'; + }; + input.oninput = () => { + prompt = input.value; + if (prompt.trim()) promptRequired = false; + clearAttention(); session.clearError(); grow(); + }; input.onkeydown = event => { if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !event.ctrlKey && !event.metaKey) { event.preventDefault(); void start(); @@ -421,6 +453,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection }; grow(); update(); } - return {mount, unmount() { mounted = null; }, selected: () => session.check(), cancel: session.cancel, - dispose: session.dispose, state: session.state}; + return {mount, unmount() { clearAttention(); promptRequired = false; mounted = null; }, + selected: () => session.check(), cancel: session.cancel, + dispose() { clearAttention(); return session.dispose(); }, state: session.state}; } From 95b91d1b41f0e487b60142ea0e978f317e15df77 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 09:10:44 +0000 Subject: [PATCH 13/53] Image worker exits after 45 s idle so its memory returns sooner --- freevideo_engine/image_gen/service.py | 2 +- freevideo_engine/image_gen/worker.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index ebdc69f0..e5056d73 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -61,7 +61,7 @@ def close(self): class Service: - def __init__(self, installation, input_directory, *, video_busy=None, idle_seconds=180.): + def __init__(self, installation, input_directory, *, video_busy=None, idle_seconds=45.): self.installation = installation self.input_directory = input_directory self.video_busy = video_busy or self._video_busy diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index 3fffbd75..c61d6286 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -75,7 +75,7 @@ def load_runner(pack_dir, device, *, progress=None): return ImageRunner(pack_dir, device=device, progress=progress) -def serve(pack_dir, *, device='cuda', idle_seconds=180., stream=None, output=None, factory=None): +def serve(pack_dir, *, device='cuda', idle_seconds=45., stream=None, output=None, factory=None): stream = sys.stdin if stream is None else stream output = sys.stdout if output is None else output factory = factory or load_runner @@ -147,7 +147,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('pack_dir', type=Path) parser.add_argument('--device', default='cuda') - parser.add_argument('--idle-seconds', type=float, default=180.) + parser.add_argument('--idle-seconds', type=float, default=45.) args = parser.parse_args() if not 0 < args.idle_seconds < float('inf'): parser.error('--idle-seconds must be positive and finite') From d0831d22efde2411728abb07c03f43b7c787c2b1 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 09:51:16 +0000 Subject: [PATCH 14/53] Image generation failures say why on the card and offer the diagnostic report --- freevideo_engine/agent/cast.py | 12 +- freevideo_engine/agent/comfy.py | 6 +- freevideo_engine/agent/errors.py | 49 +++- freevideo_engine/agent/jobs.py | 16 +- freevideo_engine/agent/schema.py | 4 + freevideo_engine/failure_details.py | 44 +++ freevideo_engine/image_gen/compose.py | 20 +- freevideo_engine/image_gen/failure.py | 276 +++++++++++++++++++ freevideo_engine/image_gen/image_pack.py | 179 ++++++++++--- freevideo_engine/image_gen/service.py | 327 ++++++++++++++++++++--- freevideo_engine/image_gen/worker.py | 29 +- freevideo_engine/launcher/Main.qml | 10 +- freevideo_engine/launcher_session.py | 41 ++- web/autopilot.js | 3 +- web/autopilot_chat.js | 16 +- web/image_editor.css | 8 + web/image_editor.js | 4 +- web/image_generation.js | 155 +++++++++-- 18 files changed, 1053 insertions(+), 146 deletions(-) create mode 100644 freevideo_engine/image_gen/failure.py diff --git a/freevideo_engine/agent/cast.py b/freevideo_engine/agent/cast.py index ab443452..7ed1f999 100644 --- a/freevideo_engine/agent/cast.py +++ b/freevideo_engine/agent/cast.py @@ -75,6 +75,8 @@ def __init__(self, code, message, next_action, *, card=None, job=None): def view(self): value = errors.build(self.code, validation=True, details=[], message=self.message, next_action=self.next_action) + if self.code in errors.IMAGE_REASONS: + value.update(errors.image_failure(self.code)) if self.card is not None: value['card'] = deepcopy(self.card) if self.job is not None: @@ -586,9 +588,9 @@ def generate(self, identity, value=None, *, by='builtin', ref=None, revision=Non raise spec.SpecError([schema.diagnostic('/face', 'false for a setting card', True)]) ready = value.get('source') != 'video' and getattr(self.jobs.port, 'image_ready', lambda: False)() source = value.get('source', 'image' if ready else 'video') - if source == 'image' and not ready: - raise CastError('pack_missing', errors.IMAGE_ERRORS['pack_missing'], - 'omit source, or use source video') + # An explicit image request goes through the image service even when + # its pack is absent: that failure owns a diagnostic report and a + # durable job/card, just like a picture requested directly. seed = secrets.randbelow(spec.MAX_SEED + 1) token = uuid.uuid4().hex[:12] if source == 'image': @@ -725,9 +727,11 @@ def _land_image(self, identity, generating, hidden, rows): row = rows[job] if row is None or row['status'] != 'succeeded': code = (row.get('error') or {}).get('code') if row else 'not_found' + code = code or (row['status'] if row else 'not_found') + detail = errors.image_failure(code, (row.get('error') or {}).get('report_id') if row else None) failure = failure or dict(code=code or row['status'], job=job, message=( 'The generation was cancelled.' if row and row['status'] == 'cancelled' - else 'The reference picture did not finish.')) + else detail['reason']['en']), **detail) continue folder = 'freevideo/agent/%s/ref-%s' % (identity, job) file = folder + '-0.png' diff --git a/freevideo_engine/agent/comfy.py b/freevideo_engine/agent/comfy.py index 179bf9d2..96deda12 100644 --- a/freevideo_engine/agent/comfy.py +++ b/freevideo_engine/agent/comfy.py @@ -332,8 +332,10 @@ def image_start(self, action, value): return self.image.start(action, value)['job'] except ValueError as error: from .jobs import IMAGE_ERRORS, IMAGE_WAIT_ERRORS - code = str(error) if str(error) in IMAGE_ERRORS else 'invalid_request' - raise JobError(code, IMAGE_ERRORS[code], retryable=code in IMAGE_WAIT_ERRORS) from None + code = getattr(error, 'code', str(error)) + code = code if code in IMAGE_ERRORS else 'invalid_request' + raise JobError(code, IMAGE_ERRORS[code], retryable=code in IMAGE_WAIT_ERRORS, + kind='image', report_id=getattr(error, 'report_id', None)) from None def image_status(self, key): return self.image.status(key) diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 027f35f9..f79540a1 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -116,6 +116,39 @@ }) +# Static bilingual image reasons; backend exception strings never enter the API. +IMAGE_REASONS = { + 'no_memory': ('Not enough GPU memory. Make the picture smaller in "Image size…" and try again.', + '显存不足。在“图像大小…”里缩小后再试。'), + 'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'), + 'pack_missing': ('Download "Image generation models" in the launcher first.', + '请先在启动器里下载“图像生成模型”。'), + 'model_unreadable': ('The image generation model files could not be read. Repair them in the launcher and try again.', + '图像生成模型的文件读不出来。在启动器里修复后再试。'), + 'device_unavailable': ('The graphics card is not available right now. Try again later.', + '显卡暂时用不了,稍后再试。'), + 'worker_exit': ('Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'), + 'timeout': ('This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'), + 'generation_failed': ('Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'), + 'invalid_media': ('A reference picture is missing or cannot be read. Choose a readable picture.', + '参考图片不存在或读不出来,请选择可读取的图片。'), + 'invalid_request': ('The picture settings are invalid. Check them and try again.', '图片参数不合法,请检查后重试。'), + 'busy': ('Another picture is being generated. Try again when it finishes.', '已有一张图在生成,完成后再试。'), + 'cancelled': ('The picture was cancelled.', '已取消这张图片。'), +} + + +def image_failure(code, report_id=None): + """Only fixed sentences and a validated opaque report ID cross the image boundary.""" + code = code if isinstance(code, str) else 'generation_failed' + en, zh = IMAGE_REASONS.get(code, IMAGE_REASONS['invalid_request'] if code.startswith('invalid_') + or code == 'too_many_images' else IMAGE_REASONS['generation_failed']) + valid = isinstance(report_id, str) and re.fullmatch(r'[0-9a-f]{32}', report_id) + return dict(reason=dict(en=en, zh=zh), report_id=report_id if valid and code not in ( + 'busy', 'cancelled', 'invalid_request', 'invalid_media', 'invalid_prompt', 'invalid_dimensions', + 'invalid_seed', 'too_many_images') else None) + + # Recovery is a function of the committed task and phase, not just an error code. IMAGE_ERRORS = { 'invalid_request': 'The picture request contains invalid fields.', @@ -131,7 +164,10 @@ 'closed': 'The image service has shut down.', 'image_unavailable': 'The image service is not loaded on this computer.', 'no_memory': 'There is not enough GPU or system memory for this picture.', - 'generation_failed': 'The image model could not finish this picture.', + 'model_unreadable': IMAGE_REASONS['model_unreadable'][0], + 'device_unavailable': IMAGE_REASONS['device_unavailable'][0], + 'timeout': IMAGE_REASONS['timeout'][0], + 'generation_failed': IMAGE_REASONS['generation_failed'][0], 'worker_exit': 'The image worker stopped before finishing this picture.', 'cancelled': 'The picture job was cancelled.', } @@ -213,7 +249,8 @@ def safe_details(value): def build(code, *, details=None, request=None, validation=False, message=None, next_action=None, - action_required=True, retry_after_seconds=None, owner=None, kind=None, stage='request', resumable=True): + action_required=True, retry_after_seconds=None, owner=None, kind=None, stage='request', resumable=True, + report_id=None): code = code if code in ERRORS else 'internal_error' _, retryable, default_message, _ = ERRORS[code] action = recovery(code, kind=kind, stage=stage, resumable=resumable) @@ -225,6 +262,8 @@ def build(code, *, details=None, request=None, validation=False, message=None, n details=(details or []) if validation else safe_details(details or []), retryable=retryable, request_id=request or request_id(), docs='/freevideo/agent/v1/guide#errors') + if kind == 'image': + result.update(image_failure(code, report_id)) if action_required: result['next'] = next_action if validation and next_action else action if code == 'busy': @@ -244,5 +283,9 @@ def validation(errors, code='invalid_request'): def text(error): - return '%s: %s%s' % (error['code'], error['message'], + reason = error.get('reason') or {} + message = reason.get('en') or error['message'] + diagnostic = image_failure(error['code'], error.get('report_id'))['report_id'] if reason else None + return '%s: %s%s%s' % (error['code'], message, + ' Diagnostic report: /freevideo/image-gen/report/' + diagnostic if diagnostic else '', ' Next: ' + error['next'] if error.get('next') else '') diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index c5623e0c..2e12cdb2 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -86,7 +86,8 @@ def __init__(self, code, message, **details): def view(self): return errors.build(self.code, details=self.details.get('details'), owner=self.details.get('owner'), - retry_after_seconds=self.details.get('retry_after_seconds')) + retry_after_seconds=self.details.get('retry_after_seconds'), + kind=self.details.get('kind'), report_id=self.details.get('report_id')) class ManagedByProduction(JobError): @@ -593,10 +594,11 @@ def finish(self, row, status, *, result=None, error=None, save=True): code = error.get('code', 'internal_error') if code == 'invalid_json' and row['kind'] in ('vlm', 'enhance'): code = 'vlm_invalid_json' - if code != error.get('code') or not error.get('request_id') or row['kind'] == 'image' and code in IMAGE_ERRORS: + if code != error.get('code') or not error.get('request_id') or row['kind'] == 'image': error = errors.build(code, details=error.get('details'), request=row.get('request_id'), action_required=code != 'cancelled', - kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from')) + kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from'), + report_id=error.get('report_id')) if error is not None: error = dict(error, request_id=row.get('request_id') or error['request_id']) if error.get('next'): @@ -1062,6 +1064,10 @@ def view(self, row, *, detail=True): result['error'] = row['error'] if row['error'].get('request_id') else errors.build( row['error'].get('code'), details=row['error'].get('details'), request=row.get('request_id'), kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from')) + if row['kind'] == 'image': + result['error'] = errors.build(result['error']['code'], kind='image', stage='terminal', + details=result['error'].get('details'), request=row.get('request_id'), + action_required=result['error']['code'] != 'cancelled', report_id=row['error'].get('report_id')) if result['error'].get('next'): result['error'] = dict(result['error'], next=errors.recovery(result['error']['code'], kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from')).replace('{id}', row['id'])) @@ -1759,7 +1765,7 @@ def poll_image(self, row): self.finish(row, 'failed', error=dict(code='unknown_job')) return phase = state.get('phase') - if phase in ('starting', 'progress', 'cancelling'): + if phase in ('queued', 'starting', 'progress', 'cancelling'): row['_phase'] = state.get('stage') if phase == 'progress' else phase elif phase == 'complete': from PIL import Image @@ -1777,7 +1783,7 @@ def poll_image(self, row): else: code = state.get('error') or 'generation_failed' if not self.wait_image(row, code): - self.finish(row, 'failed', error=dict(code=code)) + self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'))) # Local VLM ----------------------------------------------------------------------------------------- def start_vlm(self, row): diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 767fe18d..938a9ada 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -1318,6 +1318,10 @@ def openapi(): jobs={'type': 'array', 'items': {'$ref': '#/components/schemas/Job'}})), 'Error': obj(dict(code=enum(ERRORS), message=string(), details={'type': 'array'}, next=string(), retryable={'type': 'boolean'}, + reason=obj(dict(en=string(), zh=string()), ('en', 'zh'), + description='Fixed bilingual reason for an image failure.'), + report_id={'type': ['string', 'null'], 'pattern': '^[0-9a-f]{32}$', + 'description': 'Image diagnostic report ID, or null when no report exists.'}, request_id=string(), docs=string(), retry_after_seconds=number(0), card={'type': 'object'}, job=JOB, project={'type': 'object'}, current_revision=number(1, integer=True)), ('code', 'message', 'details', 'retryable', 'request_id', 'docs'))})) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 9d6a716c..ab0a64e4 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -1,5 +1,6 @@ """Readable, copyable failures for interactive callers. No GPU imports.""" import json +import errno from pathlib import Path import re import sys @@ -12,6 +13,49 @@ QUALITY_LEVELS = (('Light', '轻量'), ('Medium', '标准'), ('High', '精细'), ('Max', '极致')) +IMAGE_PACK_FAILURES = { + 'disk_space': ('Not enough disk space to install the image model.', '磁盘空间不足,无法安装图像模型。'), + 'offline': ('The network is unavailable; the image model could not be downloaded.', '网络不通,无法下载图像模型。'), + 'source_failed': ('The download source is unreachable or could not complete the image model download.', '下载源不可达或未能完成图像模型下载。'), + 'integrity_failed': ('The image model files failed verification; repair them in the launcher.', '图像模型文件校验失败,请在启动器里修复。'), + 'write_failed': ('The image model files are in use or cannot be written or deleted.', '图像模型文件被占用,或无法写入/删除。'), + 'invalid_catalog': ('The image model package version is incompatible; update FreeVideo and try again.', '图像模型包版本不匹配,请更新 FreeVideo 后重试。'), + 'busy': ('Another task is using the image model package; try again when it finishes.', '图像模型包被其他任务占用,完成后再试。'), + 'busy_video': ('A video is using the graphics card; try again when it finishes.', '视频正在使用显卡,完成后再试。'), + 'needs_repair': ('The image model files are incomplete or damaged; repair them in the launcher.', '图像模型文件不完整或已损坏,请在启动器里修复。'), + 'changed': ('The image model files changed during the check; they were kept, so check again.', '图像模型文件在检查期间发生变化,已保留,请重新检查。'), + 'unsafe_path': ('The image model folder contains a link that cannot be safely changed.', '图像模型目录含有无法安全修改的链接。'), + 'failed': ('The image model operation stopped; see the diagnostic report for details.', '图像模型操作已停止,详情见诊断报告。'), +} + + +def image_pack_error_code(error): + """Keep specific pack reasons and OS causes through wrapper exceptions.""" + seen = set() + while error is not None and id(error) not in seen: + seen.add(id(error)) + reason = getattr(error, 'reason', None) + if reason in IMAGE_PACK_FAILURES and reason != 'failed': + return reason + if isinstance(error, OSError): + if error.errno in (errno.ENOSPC, getattr(errno, 'EDQUOT', errno.ENOSPC)) or getattr(error, 'winerror', None) == 112: + return 'disk_space' + if (isinstance(error, PermissionError) or error.errno in (errno.EACCES, errno.EPERM, errno.EROFS, errno.EBUSY, errno.ETXTBSY) + or getattr(error, 'winerror', None) in (5, 32, 33)): + return 'write_failed' + if isinstance(error, BlockingIOError): + return 'busy' + if isinstance(error, (ConnectionError, TimeoutError)): + return 'offline' + error = error.__cause__ or (None if error.__suppress_context__ else error.__context__) + return 'failed' + + +def image_pack_failure(code, *, zh=False): + return dict(kind='image-pack', title=('图像模型操作未完成' if zh else 'Image model operation stopped'), + detail=IMAGE_PACK_FAILURES.get(code, IMAGE_PACK_FAILURES['failed'])[bool(zh)], action='') + + def sampling_table_details(value): """Read the worker's bounded, numeric sampling-table failure metadata.""" from .sampling_assets import UNAVAILABLE diff --git a/freevideo_engine/image_gen/compose.py b/freevideo_engine/image_gen/compose.py index f0901ca8..5272b6bd 100644 --- a/freevideo_engine/image_gen/compose.py +++ b/freevideo_engine/image_gen/compose.py @@ -120,13 +120,15 @@ def load(name): if not leaf or folder not in ('', 'freevideo-image') or any(c in name for c in ('\\', ':', '\0')): raise ValueError('invalid_media') path = (directory / folder / leaf) if folder else directory / leaf - if path.is_symlink() or not path.is_file() or path.resolve().parent != (directory / folder if folder else directory): + parent = directory / folder if folder else directory + if (parent.is_symlink() or path.is_symlink() or not path.is_file() + or path.resolve().parent != parent): raise ValueError('invalid_media') try: with Image.open(path) as image: return image.convert('RGBA') - except OSError as error: - raise ValueError('invalid_media') from error + except OSError: + raise ValueError('invalid_media') from None names = [person['image'] for person in people] scene_name = value.get('scene_image') @@ -135,12 +137,18 @@ def load(name): layout = collage(load(scene_name), [(load(p['image']), p['x'], p['height'], p['feet']) for p in people], width, height) folder = directory / 'freevideo-image' - folder.mkdir(exist_ok=True) + if folder.is_symlink() or folder.resolve().parent != directory: + raise ValueError('invalid_media') leaf = 'layout-' + uuid.uuid4().hex + '.png' - layout.save(folder / leaf) + try: + folder.mkdir(exist_ok=True) + layout.save(folder / leaf) + except OSError as error: + # A runtime layout failure must reach the service's diagnostic-report + # path, rather than being returned as a malformed request. + raise ValueError('generation_failed') from error names.append('freevideo-image/' + leaf) n = len(people) - n = len(people) prompt = ('%s Image %d is a rough layout with the characters pasted on the scene. Render image %d as one clean ' 'photograph of the same place with the same people. Keep the scene, keep everyone at exactly the same ' 'position and size, keep each face, hairstyle and clothes as in their own image. Remove every cut-out ' diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py new file mode 100644 index 00000000..891498ac --- /dev/null +++ b/freevideo_engine/image_gen/failure.py @@ -0,0 +1,276 @@ +"""Image failure codes and bounded, redacted diagnostics. No GPU imports.""" +from datetime import datetime, timezone +import math +import json +from pathlib import Path, PurePosixPath +import platform +import re +import time +import traceback + +from .. import __version__ +from ..failure_details import Redactor + + +ERRORS = frozenset(('no_memory', 'busy_video', 'busy', 'pack_missing', 'model_unreadable', + 'device_unavailable', 'worker_exit', 'timeout', 'cancelled', + 'invalid_request', 'generation_failed')) +NO_REPORT = frozenset(('busy', 'invalid_request', 'cancelled')) +STAGES = ('start', 'load', 'text', 'encode', 'denoise', 'decode') +KINDS = ('generate', 'edit', 'cast', 'compose', 'download', 'install') +MESSAGE_ZH = { + 'no_memory': '显存不足。在“图像大小…”里缩小后再试。', + 'busy_video': '视频正在生成,完成后再试。', + 'busy': '已有一张图在生成,请等它完成。', + 'pack_missing': '请先在启动器里下载“图像生成模型”。', + 'model_unreadable': '图像生成模型的文件读不出来。在启动器里修复后再试。', + 'device_unavailable': '显卡暂时用不了,稍后再试。', + 'worker_exit': '图像生成意外停止了,请再试一次。', + 'timeout': '这一张用时太长,已停止。请再试一次。', + 'generation_failed': '图像生成出错了,请再试一次。', + 'invalid_request': '图像参数不合法。', +} +_DEVICE = re.compile( + r'cuda.*(?:initializ|device.*(?:lost|unavailable|removed)|invalid device ordinal|' + r'driver.*(?:insufficient|too old|mismatch|shutting down)|context.*destroyed|system not yet initialized)|' + r'(?:no|not any).*cuda(?:-capable)? (?:gpu|device)|' + r'(?:no nvidia driver|found no nvidia driver|not compiled with cuda|' + r'cuda-capable device\(s\) (?:is/are|is|are) busy or unavailable|cuda is not available)|' + r'(?:gpu|device) (?:has been |was )?(?:lost|removed)|' + r'nvidia driver.*(?:too old|initializ|not found|unavailable)|' + r'cudaError(?:InitializationError|DeviceUninitialized|DeviceLost|SystemNotReady|InsufficientDriver)', re.I) +_MODEL = re.compile( + r'safetensors?|(?:deserializ|invalid|incomplete|too large).*header|' + r'(?:checkpoint|weights?).*(?:cannot|could not|unable|failed|corrupt|unreadable|mismatch)|' + r'pytorchstreamreader|unexpected (?:end of file|eof)', re.I) +_LIMIT = 16 * 1024 + + +class FailureError(ValueError): + """Admission rejection with the same safe status as the HTTP reply.""" + def __init__(self, status): + super().__init__(status['error']) + self.status = dict(status) + self.code = status['error'] + self.report_id = status['report_id'] + self.stage = status['stage'] + + +def classify_exception(error, *, stage='start', cancelled=False): + if cancelled: + return 'cancelled' + chain, seen = [], set() + while error is not None and id(error) not in seen: + seen.add(id(error)) + chain.append(error) + error = error.__cause__ or (None if error.__suppress_context__ else error.__context__) + for item in chain: + names = {cls.__name__ for cls in type(item).__mro__} + if isinstance(item, MemoryError) or 'OutOfMemoryError' in names: + return 'no_memory' + for item in chain: + cuda_init = any(frame.f_globals.get('__name__') == 'torch.cuda' + and frame.f_code.co_name in ('_lazy_init', '_check_driver') + for frame, _ in traceback.walk_tb(item.__traceback__)) + if _DEVICE.search(str(item)) or type(item).__name__ == 'DeferredCudaCallError' or cuda_init: + return 'device_unavailable' + for item in chain: + model_frame = any(frame.f_globals.get('__name__') in ( + 'freevideo_engine.image_gen.qwen_weights', 'freevideo_engine.image_gen.qwen_te_weights', + 'freevideo_engine.streamed_weights') for frame, _ in traceback.walk_tb(item.__traceback__)) + if (type(item).__name__ in ('SafetensorError', 'SafetensorsError') or _MODEL.search(str(item)) + or stage == 'load' and isinstance(item, (OSError, EOFError)) + or stage == 'load' and type(item).__name__ == 'JSONDecodeError' + or model_frame and isinstance(item, (ValueError, KeyError, OSError, EOFError))): + return 'model_unreadable' + return 'generation_failed' + + +def _number(value): + return (type(value) is int and 0 <= value < 2**64 + or type(value) is float and math.isfinite(value) and value >= 0) + + +def seed_redactor(redactor, value): + """Seed before allowlisting; escaped and multiline echoes must also disappear.""" + redactor.structured(value) + if isinstance(value, dict): + for key, item in value.items(): + if key in ('prompt', 'negative_prompt', 'prompt_text', 'scene', 'name') and isinstance(item, str) and item: + redactor.prompts.update((item, repr(item)[1:-1], json.dumps(item, ensure_ascii=True)[1:-1], + json.dumps(item, ensure_ascii=False)[1:-1])) + redactor.prompts.update(line for line in item.splitlines() if line.strip()) + elif isinstance(item, (dict, list)): + seed_redactor(redactor, item) + elif isinstance(value, list): + for item in value: + seed_redactor(redactor, item) + + +def clean(value, redactor, depth=0): + """Reapply redaction at the parent boundary; keep only bounded JSON values.""" + if depth > 8: + return None + if isinstance(value, str): + return redactor.text(value)[:_LIMIT] + if value is None or type(value) is bool or _number(value): + return value + if isinstance(value, dict): + value = redactor.structured(value) + return {redactor.text(str(key))[:128]: clean(item, redactor, depth + 1) + for key, item in list(value.items())[:64]} + if isinstance(value, (list, tuple)): + return [clean(item, redactor, depth + 1) for item in value[:64]] + return None + + +def safe_plan(value, redactor): + from dataclasses import fields + from .image_policy import ImagePlan + if hasattr(value, 'to_dict'): + value = value.to_dict() + if not isinstance(value, dict): + return None + return {field.name: clean(value[field.name], redactor) for field in fields(ImagePlan) if field.name in value} + + +def exception_details(error, redactor, code): + # Stack locations without source lines or locals: neither can safely enter a report. + trace, seen = [], set() + item = error + while item is not None and id(item) not in seen and len(seen) < 8: + seen.add(id(item)) + trace.append('Traceback (most recent call last):') + for frame in traceback.extract_tb(item.__traceback__, limit=32): + trace.append(' File "%s", line %d, in %s' % (redactor.path(frame.filename), frame.lineno, frame.name)) + trace.append('%s: %s' % (type(item).__name__, str(item))) + item = item.__cause__ or (None if item.__suppress_context__ else item.__context__) + if item is not None: + trace.append('Caused by:') + return dict(code=code, **{'class': redactor.text(type(error).__name__)[:128], + 'message': redactor.text(str(error))[:2048], + 'traceback': redactor.text('\n'.join(trace))[:_LIMIT]}) + + +class StageTimes: + def __init__(self, clock=None): + self.clock = clock or time.monotonic + self.started = self.entered = self.clock() + self.stage = 'start' + self.elapsed = {} + + def enter(self, stage): + if stage not in STAGES or stage == self.stage: + return + now = self.clock() + self.elapsed[self.stage] = self.elapsed.get(self.stage, 0.) + max(0., now - self.entered) + self.stage, self.entered = stage, now + + def snapshot(self): + now = self.clock() + elapsed = dict(self.elapsed) + elapsed[self.stage] = elapsed.get(self.stage, 0.) + max(0., now - self.entered) + return [dict(stage=stage, seconds=round(seconds, 6)) for stage, seconds in elapsed.items()] + + def seconds(self): + return max(0., self.clock() - self.started) + + +class Context: + def __init__(self, action, value): + self.redactor = Redactor() + self.timing = StageTimes() + self.kind = action if action in KINDS else 'generate' + self.request = {} + self.machine = {} + self.plan = None + self.pack = None + self.update_request(value) + + def update_request(self, value): + if not isinstance(value, dict): + return + seed_redactor(self.redactor, value) + images = value.get('images') + references = len(images) if isinstance(images, list) else 0 + if self.kind == 'compose' and isinstance(value.get('people'), list): + references = len(value['people']) + int(bool(value.get('scene_image'))) + if self.kind in ('generate', 'edit', 'cast'): + self.kind = value.get('kind') if value.get('kind') in ('generate', 'edit', 'cast') else ('edit' if references else 'generate') + self.request = dict(references=references) + for key in ('width', 'height', 'steps', 'seed'): + item = value.get(key) + if type(item) is int and -2**63 <= item < 2**64 and (key == 'seed' or item > 0): + self.request[key] = item + + def set_machine(self, machine): + if isinstance(machine, dict): + seed_redactor(self.redactor, machine) + self.machine = machine + + def report(self, code, *, row=None, error=None, video_busy=False, worker_restarts=0): + row = row if isinstance(row, dict) else {} + seed_redactor(self.redactor, row) + stage = row.get('stage') if row.get('stage') in STAGES else self.timing.stage + details = exception_details(error, self.redactor, code) if error is not None else row.get('error') + details = details if isinstance(details, dict) else {} + details = {key: self.redactor.text(str(details.get(key) or ''))[:limit] + for key, limit in (('class', 128), ('message', 2048), ('traceback', _LIMIT))} + details['code'] = code + stages = row.get('stages') + if isinstance(stages, list): + stages = [dict(stage=item['stage'], seconds=item['seconds']) for item in stages[:32] + if isinstance(item, dict) and item.get('stage') in STAGES and _number(item.get('seconds'))] + if not stages: + stages = self.timing.snapshot() + observed = {} + # Older installations keep this inventory in the setup receipt. Reuse + # the video's device matching rule; never start a CUDA probe to report failure. + if self.machine.get('setup_run'): + try: + from ..effort_forecast import same_device + device = (self.machine.get('device_identity') if self.machine.get('device_backend') == 'mps' + else self.machine.get('gpu_uuid')) + with (Path(self.machine['setup_run']) / 'plan.json').open('rb') as stream: + raw = stream.read(1024 * 1024 + 1) + if len(raw) <= 1024 * 1024: + found = json.loads(raw).get('inventory', {}).get('hardware', {}) + if isinstance(found, dict) and same_device(found, device): + observed = {key: found[key] for key in ('gpu_name', 'vram_total', 'ram_total', 'system') if key in found} + except (OSError, ValueError, KeyError, TypeError, AttributeError): + pass + if isinstance(self.machine.get('hardware'), dict): + observed.update(self.machine['hardware']) + device = self.machine.get('device_identity') + device = device if isinstance(device, dict) else {} + hardware = dict(gpu_name=observed.get('gpu_name') or self.machine.get('gpu_name') or device.get('name'), + system=observed.get('system') or self.machine.get('system') or platform.system()) + for key in ('vram_total', 'ram_total'): + item = observed.get(key) or self.machine.get(key) or device.get('unified_ram_bytes') + hardware[key] = item if _number(item) else None + if hardware['ram_total'] is None: + try: + from ..system import system_memory + hardware['ram_total'] = system_memory()['total_bytes'] + except (OSError, ValueError, KeyError): + pass + pack = None + if isinstance(self.pack, dict): + missing = self.pack.get('missing', []) + missing = missing if isinstance(missing, list) else [] + pack = dict(ready=bool(self.pack.get('ready')), missing=[name for name in missing[:64] + if isinstance(name, str) and not PurePosixPath(name).is_absolute() + and not any(part in ('..', '.') for part in name.split('/')) + and not any(char in name for char in ('\\', ':'))]) + size = self.pack.get('total_bytes') + if _number(size): + pack['bytes'] = size + hardware = {key: clean(value, self.redactor) for key, value in hardware.items()} + geometry = {key: self.request[key] for key in ('width', 'height') if key in self.request} + plan = safe_plan(row.get('plan') if row.get('plan') is not None else self.plan, self.redactor) + return dict(schema='freevideo.image-report', schema_version=1, + created_at=datetime.now(timezone.utc).isoformat(), kind=self.kind, + analysis=dict(version=__version__, hardware=hardware, geometry=geometry), + summary=dict(request_seconds=self.timing.seconds()), request=dict(self.request), + plan=plan, stages=stages, stage_at_failure=stage, error=details, pack=clean(pack, self.redactor), + service=dict(video_busy=bool(video_busy), worker_restarts=worker_restarts)) diff --git a/freevideo_engine/image_gen/image_pack.py b/freevideo_engine/image_gen/image_pack.py index 79e64b32..578616ff 100644 --- a/freevideo_engine/image_gen/image_pack.py +++ b/freevideo_engine/image_gen/image_pack.py @@ -7,21 +7,26 @@ or an optional busy callback. The CLI writes atomic, read-only status snapshots. """ from collections import deque +from copy import deepcopy from contextlib import ExitStack, contextmanager import errno import json import logging +import platform from pathlib import Path, PurePosixPath import re import signal import sys import threading import time +import traceback +import uuid from urllib.parse import urlsplit from .. import disk_space, model_repair, network, provision from ..locking import runtime_lock from ..monitoring import save +from ..failure_details import Redactor, image_pack_error_code, redacted_launcher_error LEDGER = 'download-verified.json' OWNERSHIP = 'owned-files.json' @@ -110,7 +115,10 @@ def wait(self): def catalog(): - spec = json.loads(Path(__file__).with_name('image_models.json').read_text(encoding='utf-8')) + try: + spec = json.loads(Path(__file__).with_name('image_models.json').read_text(encoding='utf-8')) + except (OSError, ValueError) as error: + raise PackError('invalid_catalog') from error validate_catalog(spec) return spec @@ -222,14 +230,14 @@ def _notice_ready(path): def _write_notice(folder, owned): """Put the notice into the model folder when possible; a failure is logged and the install goes on.""" - notice = _path(folder, 'NOTICE.txt') try: + notice = _path(folder, 'NOTICE.txt') if notice.exists(): return # present (ours or the user's own text): left as it is notice.write_bytes(NOTICE_TEXT) _own(folder, owned, notice) except Exception as error: - logging.warning('[FreeVideo] Could not write the image model notice: %s', error) + logging.warning('[FreeVideo] Could not write the image model notice: %s', redacted_launcher_error(error)) def status(root=None): @@ -249,8 +257,10 @@ def status(root=None): if not intact: missing.append(row['file']) files.append(dict(file=row['file'], bytes=row['bytes'], ready=intact, downloaded_bytes=retained)) - notice = _path(folder, 'NOTICE.txt') - notice_ready = _notice_ready(notice) + try: + notice_ready = _notice_ready(_path(folder, 'NOTICE.txt')) + except PackError: + notice_ready = False total = sum(r['bytes'] for r in files) available = not missing return dict(installable=True, ready=available, total_bytes=total, verified_bytes=verified_bytes, @@ -324,11 +334,18 @@ def _verify(folder, root, progress, check, full=False): for row in rows(): check() path = target(row, root) + hashed = False def checked(current, size): + nonlocal hashed + hashed = True check() progress(dict(phase='verify', done=done + current, total=total)) before = model_repair.identity(path) - if not provision.verified(path, row, ledger, mode='full' if full else 'auto', progress=checked): + intact = provision.verified(path, row, ledger, mode='full' if full else 'auto', progress=checked) + progress(dict(phase='diagnostic', diagnostic=dict(file=row['file'], verification='verified' if intact else + 'mismatch' if path.exists() else 'missing', + method='sha256' if hashed else 'receipt' if intact else 'size'))) + if not intact: ledger.pop(str(path), None) if path.exists(): model_repair.record(folder, path, row, before) @@ -357,18 +374,28 @@ def _own(folder, owned, path, row=None): save(_path(folder, OWNERSHIP), owned) -def _transfer(plan, row, path, progress, check, background=None): +def _transfer(plan, row, path, progress, check, background=None, diagnostic=None): from ..download_settings import current, SwitchRequested - failures = [] - if background is not None: - plan = dict(plan) - prior_event = plan.get('event_callback') - def event(value): - if value.get('action') in ('fallback', 'route-retry'): - failures.append((value.get('reason'), value.get('http_status'))) - if prior_event: - prior_event(value) - plan['event_callback'] = event + failures = deque(maxlen=128) + plan = dict(plan) + prior_event = plan.get('event_callback') + def event(value): + if value.get('action') in ('fallback', 'route-retry'): + failures.append((value.get('reason'), value.get('http_status'))) + # Only catalogue labels and bounded numeric transfer metadata cross + # into diagnostics. Never retain URLs, headers, paths or settings. + details = {k: value[k] for k in ('action', 'source', 'route', 'reason', 'http_status', + 'bytes', 'resume_bytes', 'observed_bytes', 'expected_bytes') if k in value} + details['file'] = row['file'] + if value.get('action') == 'verified': + details.update(verification='verified', method='sha256') + elif value.get('reason') == 'integrity-mismatch': + details.update(verification='mismatch', method='sha256') + if diagnostic: + diagnostic(dict(phase='diagnostic', diagnostic=details)) + if prior_event: + prior_event(value) + plan['event_callback'] = event while True: if background is not None: background.wait() @@ -382,6 +409,7 @@ def resource_check(): raise SwitchRequested(latest) plan['resource_check'] = resource_check try: + failures.clear() network.download(network.model_urls(plan, row), path, row['sha256'], progress, network=plan, size=row['bytes'], category=FAMILY, keep_partial=True, stall_seconds=30) @@ -395,15 +423,14 @@ def resource_check(): if background is None: raise except ConnectionError as error: - if background is None: - raise raise PackError('offline') from error except RuntimeError as error: - if background is None: - raise offline = failures and all(reason in OFFLINE_TRANSFER_REASONS and status in (None, '', '000') for reason, status in failures) - raise PackError('offline' if offline else 'source_failed') from error + reason = ('integrity_failed' if failures and failures[-1][0] in ('integrity-mismatch', 'response-size-exceeded') + else 'write_failed' if failures and failures[-1][0] == 'curl-23' + else 'offline' if offline else 'source_failed') + raise PackError(reason) from error def install(root=None, progress=None, check=None, *, engine_root=None, repair=False, _background=None): @@ -459,7 +486,7 @@ def moved(current, size, speed, **kwargs): progress(dict(phase='download', done=done + current + pending_prefixes, total=total)) transfer_check() try: - _transfer(plan, row, path, moved, transfer_check, _background) + _transfer(plan, row, path, moved, transfer_check, _background, progress) finally: # Completed files and cancellable prefixes have separate identities. for artifact in (partial, affinity): @@ -491,12 +518,20 @@ def removal_plan(root=None): for row in rows(): allowed.update((row['file'], row['file'] + '.partial', row['file'] + '.partial.source.json')) for name in allowed: + if name == 'NOTICE.txt': + continue _path(folder, name) files, skipped = [], [] for name, stamp in owned.items(): if name not in allowed or not isinstance(stamp, dict): continue - path = _path(folder, name) + try: + path = _path(folder, name) + except PackError: + if name != 'NOTICE.txt': + raise + skipped.append(name) + continue if not path.exists(): continue if _owned_intact(path, stamp): @@ -546,6 +581,15 @@ def __init__(self, root=None, *, engine_root=None, busy=None): self.transferred = 0 self.last_done = None self.background = False + self.reports = {} + self.file_checks = {} + self.events = deque(maxlen=128) + self.started = None + + def failure_report(self, report_id=None): + """An immutable, already redacted snapshot; exports survive a retry.""" + with self.lock: + return deepcopy(self.reports.get(report_id or self.value.get('report_id'))) @property def busy(self): @@ -573,6 +617,15 @@ def _rate(self, now): def _progress(self, value): value = dict(value) + diagnostic = value.pop('diagnostic', None) + if diagnostic is not None: + with self.lock: + file = diagnostic['file'] + self.file_checks.setdefault(file, {}).update({k: diagnostic[k] for k in + ('verification', 'method', 'source') if k in diagnostic}) + if 'action' in diagnostic: + self.events.append(dict(diagnostic)) + return with self.lock: if value.get('phase') == 'download': now, done = time.monotonic(), value['done'] @@ -606,7 +659,10 @@ def start(self, action='install', *, background=False): self.background = background self.samples.clear() self.transferred, self.last_done = 0, None - self.value = dict(state='running', action=action, done=0, total=0, bytes_per_second=0, reason=None) + self.file_checks.clear() + self.events.clear() + self.started = time.monotonic() + self.value = dict(state='running', action=action, done=0, total=0, bytes_per_second=0, reason=None, report_id=None) self.thread = threading.Thread(target=self._run, args=(action, self.stop, background), name='freevideo-image-pack', daemon=True) self.thread.start() @@ -634,18 +690,79 @@ def check(): options['progress'] = self._progress result = globals()[action](**options) check() + if action == 'verify' and not result['ready']: + raise PackError('integrity_failed' if any(v.get('verification') == 'mismatch' + for v in self.file_checks.values()) else 'needs_repair') if background: self._set(done=result['total_bytes'], total=result['total_bytes']) self._set(state='done', reason=None, result=result) except InterruptedError: self._set(state='cancelled', reason=None) - except PackError as error: - reason = error.reason - if background and reason not in ('disk_space', 'offline', 'source_failed', 'busy_video', 'failed'): - reason = 'source_failed' if reason == 'download_failed' else 'failed' - self._set(state='failed', reason=reason, **error.details) + except Exception as error: + self._failed(action, error) + + def _failed(self, action, error): + """Collect metadata only, even when the pack or catalogue is unreadable.""" + from .. import __version__ + code = image_pack_error_code(error) + try: + spec = catalog() + entries = rows(spec) + except Exception: + spec, entries = {}, [] + try: + pack = status(self.root) except Exception: - self._set(state='failed', reason='failed') + pack = dict(ready=False, files=[], missing=[], downloaded_bytes=0) + with self.lock: + checks, events = deepcopy(self.file_checks), list(self.events) + downloaded = self.transferred + live = {f['file']: f for f in pack['files']} + files = [] + for row in entries: + observed = live.get(row['file'], {}) + file = dict(file=row['file'], expected_bytes=row['bytes'], expected_sha256=row['sha256'], + downloaded_bytes=observed.get('downloaded_bytes', 0), ready=observed.get('ready', False), + verification='not_checked', method=None) + file.update(checks.get(row['file'], {})) + files.append(file) + pack = {k: pack[k] for k in ('ready', 'missing', 'downloaded_bytes', 'verified_bytes', 'total_bytes') if k in pack} + pack.update(name=spec.get('model_id', 'image'), repo=spec.get('repo'), revision=spec.get('revision'), + format=spec.get('format'), files=files, transferred_bytes=downloaded, + sources=[dict(file=row['file'], id=s['id'], host=urlsplit(s['url']).hostname) + for row in entries for s in row['sources']], events=events) + details = {k: v for k, v in getattr(error, 'details', {}).items() + if k in ('needed_bytes', 'free_bytes') and type(v) is int} + redactor = Redactor() + # No source lines in stack frames: a caller may have embedded user + # data in source. Retain filenames, line numbers, functions and the + # complete exception chain, redacted by the shared launcher policy. + stack, seen, cause = [], set(), error + while cause is not None and id(cause) not in seen: + seen.add(id(cause)) + stack.append('Traceback (most recent call last):\n') + stack.extend(' File "%s", line %d, in %s\n' % (Path(frame.f_code.co_filename).name, line, frame.f_code.co_name) + for frame, line in traceback.walk_tb(cause.__traceback__)) + stack.append('%s: %s\n' % (type(cause).__name__, cause)) + cause = cause.__cause__ or (None if cause.__suppress_context__ else cause.__context__) + if cause is not None: + stack.append('Caused by:\n') + report = dict(schema='freevideo.image-report', schema_version=1, + created_at=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + kind='download' if action == 'install' else 'install', operation=action, + analysis=dict(version=__version__, hardware=dict(system=platform.system()), geometry={}), + summary=dict(request_seconds=max(0, time.monotonic() - self.started) if self.started else 0), + request={}, plan=None, stages=[], stage_at_failure=self.value.get('phase', action), + error=dict(code=code, **{'class': type(error).__name__}, message=str(error), + traceback=''.join(stack), details=details), pack=pack, + service=dict(video_busy=code == 'busy_video', worker_restarts=0)) + report = json.loads(redactor.data(json.dumps(report).encode('utf-8'), '.json')) + report_id = uuid.uuid4().hex + with self.lock: + self.reports[report_id] = report + while len(self.reports) > 16: + del self.reports[next(iter(self.reports))] + self.value.update(state='failed', reason=code, report_id=report_id, **details) def main(argv=None): diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index e5056d73..7b36a814 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -1,5 +1,7 @@ -"""One image job at a time, with a leased worker retained until idle or video work.""" +"""One active image and one waiting job, with a worker leased until idle or video work.""" import atexit +from collections import OrderedDict +from copy import deepcopy import json import math from pathlib import Path @@ -11,11 +13,17 @@ import uuid from . import image_pack +from .failure import Context, ERRORS, FailureError, MESSAGE_ZH, NO_REPORT, STAGES, classify_exception BODY_LIMIT = 64 * 1024 CANCEL_GRACE = 5. MAX_IMAGES = 4 -ERRORS = {'no_memory', 'cancelled', 'generation_failed', 'invalid_request', 'busy'} +REPORT_LIMIT = 16 +INFERENCE_TIMEOUT = 600. +QUEUE_TIMEOUT = 600. +QUEUE_POLL = .1 +INVALID_ERRORS = frozenset(('invalid_request', 'invalid_prompt', 'invalid_dimensions', 'invalid_seed', + 'invalid_media', 'too_many_images')) class Worker: @@ -78,6 +86,15 @@ def __init__(self, installation, input_directory, *, video_busy=None, idle_secon self._busy = False self.closed = False self.yielding = False + self.reports = OrderedDict() + self.context = None + self.last_machine = {} + self.worker_restarts = 0 + self.worker_started = False + self.queued = None + self.queue_thread = None + self.queue_wake = threading.Event() + self.finished = OrderedDict() atexit.register(self.close) @staticmethod @@ -134,40 +151,149 @@ def request(self, value): raise ValueError('invalid_media') output.mkdir(exist_ok=True) return dict(prompt=prompt, width=dimensions[0] // 32 * 32, height=dimensions[1] // 32 * 32, - seed=seed, images=paths, path=str(output / (uuid.uuid4().hex + '.png'))) + steps=8, seed=seed, images=paths, path=str(output / (uuid.uuid4().hex + '.png'))) def start(self, action, value): if action not in ('generate', 'compose'): raise ValueError('invalid_request') + context = Context(action, value) with self.mutex: if self.closed: raise ValueError('closed') - if self.yielding or self.video_busy(): - raise ValueError('busy_video') - if self.busy() or self.thread is not None and self.thread.is_alive() and self.session_stop.is_set(): + if self.queued is not None: raise ValueError('busy') + if self.yielding: + context.set_machine(self.last_machine) + raise FailureError(self.failed('busy_video', context, video_busy=True)) + video = self.video_busy() + waiting = (video or self.busy() or self.thread is not None + and self.thread.is_alive() and self.session_stop.is_set()) + raw = deepcopy(value) + root, machine, prepared = self.prepare(action, raw, context, waiting=waiting) + key = secrets.token_urlsafe(24) + if waiting: + self.queued = dict(key=key, action=action, value=raw, context=context, + state=dict(phase='queued', queued_behind='video' if video else 'image'), + video_since=time.monotonic() if video else None) + if self.queue_thread is None or not self.queue_thread.is_alive(): + self.queue_thread = threading.Thread(target=self.wait_queued, + name='freevideo-image-queue', daemon=True) + self.queue_thread.start() + else: + self.begin(key, root, machine, prepared, context) + return dict(job=key) + + def prepare(self, action, value, context, *, waiting=False): + """Validate at admission and again at dispatch, against the original request.""" + try: root, machine = self.installation() + except (OSError, ValueError) as error: + raise FailureError(self.failed('pack_missing', context, error=error)) from None + context.set_machine(machine) + self.last_machine = machine + try: if not image_pack.ready(machine['model_root']): raise ValueError('pack_missing') + requested = {name: value.get(name) for name in ('width', 'height')} if isinstance(value, dict) else {} if action == 'compose': from . import compose value = compose.build(value, self.input_directory()) value = self.request(value) - self.preflight(machine, value) - self.key = secrets.token_urlsafe(24) - self.stop.clear() - self.state = dict(phase='starting') - self._busy = True - if self.thread is None or not self.thread.is_alive(): - self.session_stop = threading.Event() - self.requests = queue.Queue() - self.thread = threading.Thread(target=self.work, args=(root, machine), - name='freevideo-image-gen', daemon=True) - self.requests.put(value) - self.thread.start() - else: - self.requests.put(value) - return dict(job=self.key) + value['kind'] = context.kind + context.update_request(value) + context.plan = self.preflight(machine, value, fit=not waiting) + # Live free memory while another job owns the GPU is advisory. Keep + # the original request for dispatch, when the owner has released it. + if not waiting and context.plan is not None and not context.plan['feasible']: + raise ValueError('no_memory') + if 'fitted' in value: + value['fitted']['requested'] = requested + context.update_request(value) + return root, machine, value + except ValueError as error: + if str(error) in INVALID_ERRORS: + raise + code = str(error) if str(error) in ERRORS else classify_exception(error) + raise FailureError(self.failed(code, context, error=error)) from None + except Exception as error: + raise FailureError(self.failed(classify_exception(error), context, error=error)) from None + + def remember(self, key, state): + self.finished[key] = deepcopy(state) + while len(self.finished) > REPORT_LIMIT: + self.finished.popitem(last=False) + + def begin(self, key, root, machine, value, context): + if self.key is not None: + self.remember(self.key, self.state) + self.key = key + self.stop.clear() + self.state = dict(phase='starting', stage='start') + if 'fitted' in value: + self.state['fitted'] = deepcopy(value['fitted']) + self.context = context + self._busy = True + if self.thread is None or not self.thread.is_alive(): + self.session_stop = threading.Event() + self.requests = queue.Queue() + self.thread = threading.Thread(target=self.work, args=(root, machine), + name='freevideo-image-gen', daemon=True) + self.requests.put(value) + self.thread.start() + else: + self.requests.put(value) + + def discard_queued(self, state): + self.remember(self.queued['key'], state) + self.queued = None + self.queue_wake.set() + + def wait_queued(self): + while True: + with self.mutex: + pending = self.queued + if pending is None: + # Clearing under the lock lets a new admission start a reader. + self.queue_thread = None + return + if self.closed: + self.discard_queued(dict(phase='cancelled')) + continue + try: + video = self.yielding or self.video_busy() + except Exception as error: + self.discard_queued(self.failed(classify_exception(error), pending['context'], error=error)) + continue + pending['state']['queued_behind'] = 'video' if video else 'image' + if video: + if pending['video_since'] is None: + pending['video_since'] = time.monotonic() + if time.monotonic() - pending['video_since'] >= QUEUE_TIMEOUT: + state = self.failed('timeout', pending['context'], + error=TimeoutError('video queue timeout'), video_busy=True) + self.discard_queued(state) + continue + else: + pending['video_since'] = None + stopping = (self.thread is not None and self.thread.is_alive() + and self.session_stop.is_set()) + if not self.busy() and not stopping: + try: + root, machine, value = self.prepare(pending['action'], pending['value'], + pending['context']) + except FailureError as error: + self.discard_queued(error.status) + except Exception as error: + # A previously valid reference may disappear while waiting. + code = ('generation_failed' if str(error) in INVALID_ERRORS + else classify_exception(error)) + self.discard_queued(self.failed(code, pending['context'], error=error)) + else: + self.queued = None + self.begin(pending['key'], root, machine, value, pending['context']) + continue + self.queue_wake.clear() + self.queue_wake.wait(QUEUE_POLL) def memory_budget(self, machine): """Bound the driver query, without importing Torch or starting a worker.""" @@ -209,7 +335,7 @@ def read(): except (OSError, RuntimeError, AttributeError, ValueError): return None - def preflight(self, machine, value): + def preflight(self, machine, value, *, fit=True): budget = self.memory_budget(machine) if budget is None: return @@ -222,20 +348,88 @@ def preflight(self, machine, value): reference_pixels += round(image.width * scale) * round(image.height * scale) plan = plan_image(gpu_budget_bytes=budget, width=value['width'], height=value['height'], reference_pixels=reference_pixels) - if not plan.feasible: - raise ValueError('no_memory') + if fit and not plan.feasible: + width, height = value['width'], value['height'] + landscape = width >= height + long, short = max(width, height), min(width, height) + # At most 48 probes for the accepted 2048-pixel limit. Round the + # proportional short side to its nearest 32-pixel grid point. + for side in range(long - 32, 511, -32): + other = (short * side + long * 16) // (long * 32) * 32 + if other < 512: + break + fitted_width, fitted_height = (side, other) if landscape else (other, side) + candidate = plan_image(gpu_budget_bytes=budget, width=fitted_width, height=fitted_height, + reference_pixels=reference_pixels) + if candidate.feasible: + value.update(width=fitted_width, height=fitted_height, + fitted=dict(width=fitted_width, height=fitted_height, + requested=dict(width=width, height=height))) + return candidate.to_dict() + return plan.to_dict() + + def failed(self, code, context, *, row=None, error=None, video_busy=None): + """One safe failure status, used by admission, worker, HTTP and Agent callers.""" + code = code if isinstance(code, str) and code in ERRORS else 'generation_failed' + if code == 'cancelled': + return dict(phase='cancelled') + stage = row.get('stage') if isinstance(row, dict) else None + stage = stage if stage in STAGES else context.timing.stage + result = dict(phase='failed', error=code, report_id=None, stage=stage) + if code in MESSAGE_ZH: + result['message_zh'] = MESSAGE_ZH[code] + if code not in NO_REPORT: + if video_busy is None: + try: + video_busy = self.yielding or self.video_busy() + except Exception: + video_busy = False + if context.pack is None and context.machine.get('model_root'): + try: + context.pack = image_pack.status(context.machine['model_root']) + except Exception: + # A missing/broken pack must not prevent reporting the original error. + pass + report = context.report(code, row=row, error=error, video_busy=video_busy, + worker_restarts=self.worker_restarts) + with self.mutex: + report_id = uuid.uuid4().hex + self.reports[report_id] = report + while len(self.reports) > REPORT_LIMIT: + self.reports.popitem(last=False) + result['report_id'] = report_id + return result + + def report(self, report_id): + if (not isinstance(report_id, str) or len(report_id) != 32 + or any(char not in '0123456789abcdef' for char in report_id)): + return None + with self.mutex: + report = self.reports.get(report_id) + # Callers cannot mutate a retained report through an in-process reference. + return json.loads(json.dumps(report, allow_nan=False)) if report is not None else None def status(self, key): with self.mutex: - if (not isinstance(key, str) or not self.key - or not secrets.compare_digest(key.encode('utf-8'), self.key.encode('utf-8'))): - raise ValueError('unknown_job') - return dict(self.state) + if isinstance(key, str): + def matches(candidate): + return candidate is not None and secrets.compare_digest(key.encode('utf-8'), candidate.encode('utf-8')) + if matches(self.key): + return deepcopy(self.state) + if self.queued is not None and matches(self.queued['key']): + return deepcopy(self.queued['state']) + for candidate, state in self.finished.items(): + if matches(candidate): + return deepcopy(state) + raise ValueError('unknown_job') def cancel(self, key): with self.mutex: row = self.status(key) - if not self.busy(): + if row['phase'] == 'queued': + self.discard_queued(dict(phase='cancelled')) + return dict(phase='cancelled') + if key != self.key or not self.busy(): return row self.stop.set() self.state['phase'] = 'cancelling' @@ -253,6 +447,10 @@ def stop_for_generation(self, only_idle=False): self.yielding = True self.stop.set() self.session_stop.set() + if self.queued is not None: + self.queued['state']['queued_behind'] = 'video' + if self.queued['video_since'] is None: + self.queued['video_since'] = time.monotonic() thread = self.thread try: if thread is not None and thread is not threading.current_thread(): @@ -260,12 +458,18 @@ def stop_for_generation(self, only_idle=False): finally: with self.mutex: self.yielding = False + self.queue_wake.set() return True def close(self): with self.mutex: self.closed = True + if self.queued is not None: + self.discard_queued(dict(phase='cancelled')) + queue_thread = self.queue_thread self.stop_for_generation() + if queue_thread is not None and queue_thread is not threading.current_thread(): + queue_thread.join() def environment(self, root): from ..comfy_bridge import source_root @@ -293,6 +497,7 @@ def work(self, root, machine): from .. import processes, resident_process session = None failure = 'worker_exit' + failure_error = None try: from ..background_compile import COMPILE from ..encoder_prewarm import IDLE @@ -323,6 +528,8 @@ def work(self, root, machine): with self.mutex: self.worker = session self.process = session.process + self.worker_restarts += int(self.worker_started) + self.worker_started = True while not self.session_stop.is_set(): try: value = self.requests.get(timeout=.05) @@ -339,6 +546,7 @@ def work(self, root, machine): with self.mutex: self.worker = session self.process = session.process + self.worker_restarts += 1 self.infer(session, value) finally: # Keep the parent's lease until the whole owned process tree exits. @@ -352,23 +560,36 @@ def work(self, root, machine): processes.stop(session.process, grace=5) session.process.wait() session.close() - except BlockingIOError: + except BlockingIOError as error: failure = 'busy_video' - except Exception: + failure_error = error + except TimeoutError as error: + failure = 'timeout' + failure_error = error + except Exception as error: failure = 'worker_exit' + failure_error = error finally: with self.mutex: self.process = self.worker = None self.session_stop.set() if self.busy(): + context = self.context or Context('generate', {}) + fitted = self.state.get('fitted') self.state = (dict(phase='cancelled') if self.stop.is_set() - else dict(phase='failed', error=failure)) + else self.failed(failure, context, error=failure_error, + video_busy=failure == 'busy_video')) + if fitted is not None: + self.state['fitted'] = fitted self._busy = False + self.context = None + self.queue_wake.set() def infer(self, session, value): + context = self.context or Context('generate', value) session.send(action='generate', **value) cancelled_at = None - deadline = time.monotonic() + 600 + deadline = time.monotonic() + INFERENCE_TIMEOUT while True: if self.stop.is_set() and cancelled_at is None: session.send(action='cancel') @@ -378,7 +599,7 @@ def infer(self, session, value): if self.session_stop.is_set(): return if time.monotonic() > deadline: - raise TimeoutError('worker_exit') + raise TimeoutError('timeout') try: row = session.rows.get(timeout=.05) except queue.Empty: @@ -388,11 +609,14 @@ def infer(self, session, value): phase = row.get('phase') if phase == 'progress': stage, fraction = row.get('stage'), row.get('fraction') - if (stage in ('load', 'text', 'encode', 'denoise', 'decode') and type(fraction) in (int, float) + if (stage in STAGES and type(fraction) in (int, float) and math.isfinite(fraction) and 0 <= fraction <= 1): + # The limit is for a worker that stops reporting, not for a slow first load. + deadline = time.monotonic() + INFERENCE_TIMEOUT with self.mutex: if self.state.get('stage') == stage and type(self.state.get('fraction')) in (int, float): fraction = max(fraction, self.state['fraction']) + context.timing.enter(stage) self.state.update(phase='cancelling' if self.stop.is_set() else 'progress', stage=stage, fraction=fraction) elif phase in ('result', 'error'): @@ -400,9 +624,7 @@ def infer(self, session, value): result = dict(phase='cancelled') elif phase == 'error': code = row.get('code') - result = dict(phase='failed', error=code if code in ERRORS else 'generation_failed') - if code == 'no_memory': - result['message_zh'] = '内存或显存不足,请降低图像尺寸或减少参考图。' + result = self.failed(code, context, row=row) else: path = Path(value['path']) if row.get('path') != str(path) or path.is_symlink() or not path.is_file(): @@ -418,8 +640,12 @@ def infer(self, session, value): result = dict(phase='complete', file='freevideo-image/' + path.name, seconds=seconds, plan=row.get('plan')) with self.mutex: + if 'fitted' in self.state: + result['fitted'] = self.state['fitted'] self.state = result self._busy = False + self.context = None + self.queue_wake.set() return @@ -473,6 +699,17 @@ async def info(request): except (OSError, ValueError): return web.json_response(dict(error='setup_required'), status=400) + @server.routes.get('/freevideo/image-gen/report/{report_id}') + async def report(request): + value = service.report(request.match_info['report_id']) + headers = {'Cache-Control': 'no-store'} + if value is None: + return web.json_response(dict(error='unknown_report'), status=404, headers=headers) + from datetime import datetime, timezone + stamp = datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S') + headers['Content-Disposition'] = 'attachment; filename="FreeVideo-report-%s.json"' % stamp + return web.json_response(value, headers=headers) + @server.routes.post('/freevideo/image-gen/{action}') async def action(request): header = request.headers.get('X-FreeVideo-Image', '') @@ -500,7 +737,15 @@ async def action(request): else: result = service.start(name, value) return web.json_response(result) + except FailureError as error: + return web.json_response(error.status, status=400) except (ValueError, OSError) as error: - codes = {'invalid_request', 'invalid_prompt', 'invalid_dimensions', 'invalid_seed', 'invalid_media', - 'too_many_images', 'busy', 'busy_video', 'no_memory', 'pack_missing', 'unknown_job', 'closed'} - return web.json_response(dict(error=str(error) if str(error) in codes else 'invalid_request'), status=400) + code = str(error) + if code in ('unknown_job', 'closed'): + result = dict(error=code) + else: + code = code if code in ERRORS else 'invalid_request' + result = dict(phase='failed', error=code, report_id=None, stage='start') + if code in MESSAGE_ZH: + result['message_zh'] = MESSAGE_ZH[code] + return web.json_response(result, status=400) diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index c61d6286..4d9afceb 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -9,6 +9,8 @@ import threading import time +from .failure import Context, classify_exception, exception_details, safe_plan + class Inbox: """Read cancellation while the main thread is inside a denoising step.""" @@ -97,15 +99,23 @@ def send(**row): if value is None or inbox.closed.is_set(): break started = time.monotonic() + context = Context('generate', value) result = None try: from PIL import Image with ExitStack() as stack, redirect_stdout(sys.stderr): if inbox.cancel.is_set(): raise InterruptedError('cancelled') - progress = lambda stage, fraction: send(phase='progress', stage=stage, fraction=fraction) + def progress(stage, fraction): + context.timing.enter(stage) + send(phase='progress', stage=stage, fraction=fraction) + if runner is None: + context.timing.enter('load') runner = factory(pack_dir, device, progress=progress) + if hasattr(runner, 'last_plan'): + runner.last_plan = None + context.timing.enter('start') references = [] for path in value.get('images', []): image = stack.enter_context(Image.open(path)) @@ -123,13 +133,18 @@ def send(**row): image.save(target, format='PNG') plan = runner.stats().get('plan') result = dict(phase='result', path=value['path'], seconds=time.monotonic() - started, plan=plan) - except MemoryError: - result = dict(phase='error', code='no_memory', message_zh='内存或显存不足,请降低图像尺寸或减少参考图。') except Exception as error: - # Exceptions may echo prompts, filenames or model paths. - code = ('cancelled' if inbox.cancel.is_set() else - 'no_memory' if type(error).__name__ == 'OutOfMemoryError' else 'generation_failed') - result = dict(phase='error', code=code) + code = classify_exception(error, stage=context.timing.stage, cancelled=inbox.cancel.is_set()) + plan = None + if runner is not None: + try: + plan = safe_plan(runner.stats().get('plan'), context.redactor) + except Exception: + # Diagnostics must not replace the inference exception. + pass + result = dict(phase='error', code=code, stage=context.timing.stage, + seconds=context.timing.seconds(), stages=context.timing.snapshot(), plan=plan, + error=exception_details(error, context.redactor, code)) finally: with inbox.mutex: inbox.busy = False diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index d5681062..bd57d3bb 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -890,12 +890,14 @@ ApplicationWindow { objectName: "imagePackCard" visible: !!s.image_pack && s.image_pack.visible Layout.fillWidth: true; padding: 22; spacing: 10 + color: s.image_pack.install.state === "failed" ? theme.dangerSubtle : theme.surface + border.color: s.image_pack.install.state === "failed" ? theme.dangerLine : theme.border RowLayout { Layout.fillWidth: true; spacing: 14 ColumnLayout { Layout.fillWidth: true; spacing: 4 FText { objectName: "imagePackTitle"; text: s.image_pack.title; font.weight: Font.DemiBold; Layout.fillWidth: true } - FText { objectName: "imagePackStatus"; text: s.image_pack.detail; color: theme.muted; Layout.fillWidth: true } + FText { objectName: "imagePackStatus"; visible: s.image_pack.install.state !== "failed"; text: s.image_pack.detail; color: theme.muted; Layout.fillWidth: true } } FText { objectName: "imagePackSize"; text: bytes(s.image_pack.total_bytes).replace(" ", "\u00a0"); color: theme.muted } FButton { objectName: "imagePackInstall"; visible: !s.image_pack.ready && !s.image_pack.busy; text: s.image_pack.downloaded_bytes > 0 ? t("Resume", "继续") : t("Download", "下载"); enabled: s.image_pack.can_act; onClicked: backend.imagePackAction("install") } @@ -903,6 +905,12 @@ ApplicationWindow { FButton { objectName: "imagePackRepair"; visible: s.image_pack.repairable && s.image_pack.downloaded_bytes > 0 && !s.image_pack.busy; text: t("Repair", "修复"); flat: true; enabled: s.image_pack.can_act; onClicked: backend.imagePackAction("repair") } FButton { objectName: "imagePackRemove"; visible: s.image_pack.removable_bytes > 0 && !s.image_pack.busy; text: t("Remove", "删除"); flat: true; enabled: s.image_pack.can_act; onClicked: backend.imagePackAction("remove") } } + FText { objectName: "imagePackFailureReason"; visible: s.image_pack.install.state === "failed"; text: s.image_pack.detail; color: theme.danger; Layout.fillWidth: true } + Flow { + visible: s.image_pack.install.state === "failed" && !!s.image_pack.report_id + Layout.fillWidth: true; spacing: 8 + FButton { objectName: "imagePackExport"; text: t("Export report", "导出报告"); enabled: s.report.status !== "running"; onClicked: backend.exportReport() } + } FMeter { objectName: "imagePackProgress"; visible: s.image_pack.busy; Layout.fillWidth: true; active: visible fraction: s.image_pack.install.total > 0 ? s.image_pack.install.done / s.image_pack.install.total : -1 diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index c6201eb6..564567b9 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -677,7 +677,8 @@ def image_pack_action(self, action): if self.image_installer and self.image_installer.busy: return # An install is the quiet kind: it stops while a video is generating and carries on afterwards. - self.image_installer = image_pack.Installer(root, engine_root=engine, busy=self.generating) + if not (self.image_installer and self.image_installer.root == root and self.image_installer.engine_root == engine): + self.image_installer = image_pack.Installer(root, engine_root=engine, busy=self.generating) self.image_installer.start(action, background=action == 'install') def generating(self): @@ -696,23 +697,28 @@ def image_disk_fits(self, total): def image_pack_view(self): from .image_gen import image_pack - if not self.installation_engine(): + from .failure_details import image_pack_failure + try: total = sum(r['bytes'] for r in image_pack.rows()) + except image_pack.PackError: + total = 0 + if not self.installation_engine(): return dict(installable=False, ready=False, state='missing', visible=False, busy=False, downloaded_bytes=0, verified_bytes=0, total_bytes=total, bytes=total, title=self.t(*NAMES['image']), detail=self.t('Not installed', '未安装'), - install={}, can_act=False, removable_bytes=0, repairable=False) + install={}, can_act=False, removable_bytes=0, repairable=False, failure={}, report_id=None) root, engine = self._image_location() try: value = image_pack.status(root) removable = image_pack.removal_plan(root)['bytes'] except (OSError, ValueError, image_pack.PackError): value = dict(installable=False, ready=False, state='invalid', downloaded_bytes=0, verified_bytes=0, - total_bytes=sum(r['bytes'] for r in image_pack.rows()), bytes=0) + total_bytes=total, bytes=0) removable = 0 installer = self.image_installer active = installer.state() if installer and installer.root == root and installer.engine_root == engine else {} - busy = bool(installer and installer.busy) + busy = bool(active and installer.busy) + failure = {} labels = {'missing': ('Not installed', '未安装'), 'partial': ('Paused', '已暂停'), 'ready': ('Ready', '已就绪'), 'invalid': ('Needs repair', '需要修复')} detail = self.t(*labels[value['state']]) @@ -727,18 +733,23 @@ def image_pack_view(self): elif active.get('state') == 'cancelled': detail = self.t('Paused', '已暂停') elif active.get('state') == 'failed': - labels = {'disk_space': ('Not enough disk space', '磁盘空间不足'), - 'needs_repair': ('Needs repair', '需要修复'), 'busy': ('In use', '正在使用'), - 'unsafe_path': ('Needs repair', '需要修复'), 'busy_video': ('Paused while generating', '生成时暂停'), - 'offline': ('No network', '网络不可用'), 'source_failed': ('Download failed', '下载失败')} - detail = self.t(*labels.get(active.get('reason'), ('Stopped', '未完成'))) - repairable = repairable or active.get('reason') in ('needs_repair', 'unsafe_path') + failure = image_pack_failure(active.get('reason'), zh=self.language.startswith('zh')) + detail = failure['detail'] + repairable = repairable or active.get('reason') in ('needs_repair', 'integrity_failed') blocked = (self.controller.busy or self.importer.busy or self.cleaner.busy or self.archives_busy or self.closing or self.engine_updating or self.update_intent or self.upgrade.active or self.model_switch or self.updater and self.updater.busy) return dict(value, visible=bool(self.installation_engine()), title=self.t(*NAMES['image']), detail=detail, busy=busy, install=active, can_act=not (blocked or busy), removable_bytes=removable, - repairable=repairable) + repairable=repairable, failure=failure, report_id=active.get('report_id')) + + def image_pack_report(self): + if self.image_installer: + root, engine = self._image_location() + if self.image_installer.root == root and self.image_installer.engine_root == engine: + report = self.image_installer.failure_report() + return report if isinstance(report, dict) else None + return None def start_image_model_after_install(self): """The installer's "also install the image model": once ComfyUI opens, the download runs in the background.""" @@ -984,6 +995,12 @@ def diagnostic_error(self): value = state.get('error') or '' if alias_failure(value) and value not in raw: raw += ('\n' if raw else '') + value + pack_report = self.image_pack_report() + if pack_report: + # The existing ZIP exporter retains launcher/error.txt in full. + # Capture the structured, redacted pack snapshot here before a + # retry can replace it; collection needs no writable model folder. + raw += ('\n\n' if raw else '') + 'Image model diagnostic report:\n' + json.dumps(pack_report, ensure_ascii=False, indent=2) return redacted_launcher_error(raw, [(self.token, '')] if self.token else []) def export_report(self, output): diff --git a/web/autopilot.js b/web/autopilot.js index c3829b46..ffca7b44 100644 --- a/web/autopilot.js +++ b/web/autopilot.js @@ -4,6 +4,7 @@ import { agentConnectCard, connectionLabel } from './agent_connect.js'; import { about, agentChat, jobName } from './autopilot_chat.js'; import { openEditorView } from './editor_loader.js'; import { closeDialog } from './motion.js'; +import { productVersion } from './updates.js'; import { openSetup } from './setup.js'; import { openExtendEditor, openStudio, showNodes } from './studio.js'; import { viewChanged, viewSwitch } from './view_navigation.js'; @@ -44,7 +45,7 @@ export function openAutopilotView(node, {studio = null} = {}) { const card = agentConnectCard(t, api, () => inSight() && !card.element.hidden); // Leaving for another view waits until this page has closed, so the view underneath is never covered twice. const closed = () => dialog.open ? new Promise(done => { dialog.addEventListener('close', done, {once: true}); closeDialog(dialog); }) : null; - const chat = agentChat(t, api, {shown: inSight, + const chat = agentChat(t, api, {shown: inSight, productVersion, // Creations continues the work on the editor timeline; this page gives way first. open: async job => (await import('./library.js')).openLibrary(t, {selectedWork: job.work, back: t('Back to chat', '返回对话'), extend: async (record, options) => { await closed(); return openExtendEditor(record, node, {studio, ...options}); }}), diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index db97647c..eed61ee5 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -3,6 +3,7 @@ import {agentGet, agentPost} from './agent_session.js'; import { estimatedFraction, remainingAbove, remainingShown } from './generation_progress.js'; import {canDeleteConversation, conversationMenu, emptyConversations, hasConversations, mergeConversations, removeConversation, runningElsewhere, selectConversation} from './autopilot_conversations.js'; +import {renderImageFailure} from './image_generation.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./autopilot_chat.css', import.meta.url).href; document.head.append(css); @@ -137,7 +138,7 @@ function newId() { // open(job) shows a finished video in Creations, edit(job) takes it to the editing view. shown() says whether the // user can see the conversation; the page calls sync() whenever that may have changed. changed(view) follows every // answer: {known, agent, current, active, any}. -export function agentChat(t, api, {open, edit, shown = () => true, changed = () => {}, interval = 1500} = {}) { +export function agentChat(t, api, {open, edit, shown = () => true, changed = () => {}, interval = 1500, productVersion = () => null} = {}) { const element = el('section', 'fv-chat'), main = el('div', 'fv-chat-main'), scroller = el('div', 'fv-chat-scroll'), log = el('ol', 'fv-chat-log'); log.setAttribute('role', 'log'); log.setAttribute('aria-label', t('Conversation', '对话')); const down = press('fv-chat-down', null, () => { scroller.scrollTo({top: scroller.scrollHeight, behavior: 'smooth'}); }); @@ -505,7 +506,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // Jobs without a picture (a check, a prompt) are one quiet line while they run, and nothing after. // A picture takes seconds: the same quiet line while it is made, then the picture. const card = VIDEOS.includes(job.kind || 'generate') || (job.kind === 'image' && !WORKING.includes(job.status)); - const next = card ? jobMode(job) + (jobMode(job) === 'done' ? `:${job.play}:${job.thumbnail}:${job.work}` : '') + const next = card ? jobMode(job) + (jobMode(job) === 'done' ? `:${job.play}:${job.thumbnail}:${job.work}` + : jobMode(job) === 'failed' ? `:${job.error?.code}:${job.error?.report_id}` : '') : WORKING.includes(job.status) ? 'quiet' : 'none'; if (next !== mode) { mode = next; item.dataset.job = job.id; item.dataset.mode = next.split(':')[0]; item.hidden = next === 'none'; @@ -514,9 +516,13 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () else if (next.startsWith('done')) apply = done(item, job); else if (next === 'quiet') { item.replaceChildren(el('span', 'fv-chat-dots'), el('span', null, t(...(QUIET[job.kind] || ['Working', '进行中'])))); apply = null; } else if (next === 'stopped') { item.replaceChildren(el('span', null, t('Stopped', '已停止'))); apply = null; } - else if (next === 'failed') { - item.replaceChildren(el('span', null, t('Could not generate', '没有生成出来'))); - // Trying again says the same sentence to the agent once more; why it failed is the agent's to say. + else if (next.startsWith('failed')) { + if (job.kind === 'image') { + const host = el('div'); + renderImageFailure(host, {error: job.error?.code || 'generation_failed', report_id: job.error?.report_id}, t, + {productVersion, fetchApi: (path, options) => api.fetchApi(path, options)}); + item.replaceChildren(host); + } else item.replaceChildren(el('span', null, t('Could not generate', '没有生成出来'))); if (retry) item.append(press('fv-chat-retry', t('Retry', '重试'), retry)); apply = null; } else { item.replaceChildren(); apply = null; } diff --git a/web/image_editor.css b/web/image_editor.css index a1084a90..89db2ed0 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -85,6 +85,12 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-generation[data-running=true]>.fv-ie-gen-note{white-space:nowrap} .fv-ie-generation>.fv-ie-button{grid-column:4;min-height:32px} .fv-ie-generation[data-message=true]>.fv-ie-gen-note{max-width:260px} +/* Image failures use the video's report button and submission card on their own row. */ +.fv-ie-generation[data-failed=true]>.fv-ie-gen-note{grid-column:1/-1;grid-row:2;max-width:none} +.fv-ie-generation[data-failed=true]>.fv-ie-button{grid-row:1} +.fv-ie-gen-note>.fv-result-links{display:grid;gap:8px} +.fv-ie-gen-note .fv-image-failure-reason{color:var(--fv-danger)} +.fv-ie-gen-note>.fv-result-links>.fv-actions{justify-content:flex-start} /* Same accent ramp and easing as the shared generation progress meters. */ .fv-ie-gen-meter{position:absolute;bottom:0;left:0;right:0;height:2px;overflow:hidden;background:var(--fv-raised)} .fv-ie-gen-meter i{display:block;height:100%;background:linear-gradient(90deg,var(--fv-accent),var(--fv-accent-ramp));transition:width .5s var(--fv-ease-out)} @@ -268,6 +274,8 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-generation[data-empty=true]>#fv-ie-gen-ratio{grid-column:2;grid-row:1} .fv-ie-generation[data-empty=true]>.fv-ie-button{grid-column:2;grid-row:2} .fv-ie-generation[data-empty=true]>.fv-ie-gen-note{grid-column:1/-1;grid-row:3} + .fv-ie-generation[data-failed=true]>.fv-ie-gen-note{grid-row:3} + .fv-ie-generation[data-failed=true]>.fv-ie-button{grid-row:2} .fv-ie-actions .fv-ie-button:not(.fv-ie-primary):not(.fv-ie-danger){padding:5px 9px} .fv-ie-popover{left:12px;width:auto} .fv-ie-reset{display:none} diff --git a/web/image_editor.js b/web/image_editor.js index e4592cbf..ba45039a 100644 --- a/web/image_editor.js +++ b/web/image_editor.js @@ -9,6 +9,7 @@ import * as commands from './image_commands.js'; import {FONT_KEYS, fontStack, fontLoaded, loadFont, loadFonts} from './fonts.js'; import {api} from '../../scripts/api.js'; import {createImageGenClient, createImageGeneration, imageGenerationSupported, blankCanvasSize, blankCanvasRatio} from './image_generation.js'; +import {productVersion} from './updates.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./image_editor.css', import.meta.url).href; @@ -226,7 +227,7 @@ export async function openImageEditor({source = null, name = source || '', targe document.body.append(dialog); const generation = createImageGeneration({client: imageGenClient || createImageGenClient(api.fetchApi.bind(api)), renderer, t, getDoc: () => doc, getSelection: () => selection, commit: commitInserted, el, button, segmented, - library, resolve, icon, onRatioChange: setBlankCanvasRatio}); + library, resolve, icon, onRatioChange: setBlankCanvasRatio, productVersion, fetchApi: (path, options) => api.fetchApi(path, options)}); // ----- tools ---------------------------------------------------------------- const TOOLS = [ @@ -517,6 +518,7 @@ export async function openImageEditor({source = null, name = source || '', targe clearPanel(optionsBar); optionsBar.classList.remove('fv-ie-generation'); delete optionsBar.dataset.running; delete optionsBar.dataset.message; delete optionsBar.dataset.empty; + delete optionsBar.dataset.failed; if (tool === 'generate') { generation.mount(optionsBar); return; } if (!doc.layers.length) return; const add = (...nodes) => optionsBar.append(...nodes); diff --git a/web/image_generation.js b/web/image_generation.js index 15b91435..d074f3d3 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -2,13 +2,14 @@ // owns its document/history; this module inserts a result through its commands. import * as commands from './image_commands.js'; import {orientMatrix, rasterKinds, scaleSelection} from './image_editor_engine.js'; +import {saveReport, reportIssue, showReportTip} from './report_issue.js'; export const IMAGE_GEN_PROTOCOL = Object.freeze({ root: '/freevideo/image-gen', header: 'X-FreeVideo-Image', upload: '/upload/image', installed: 'ready', phase: 'phase', bytes: 'bytes', downloading: 'downloading', token: 'token', job: 'job', prompt: 'prompt', width: 'width', height: 'height', images: 'images', seed: 'seed', stage: 'stage', fraction: 'fraction', file: 'file', error: 'error', name: 'name', subfolder: 'subfolder', - states: {starting: 'queued', progress: 'running', complete: 'done', failed: 'error', + states: {queued: 'waiting', starting: 'queued', progress: 'running', complete: 'done', failed: 'error', cancelling: 'running', cancelled: 'error'}, }); export const IMAGE_GEN_POLL_MS = 500; @@ -33,7 +34,9 @@ export function blankCanvasSize(ratio, sizes = null) { const units = Math.floor(Math.sqrt(1024 ** 2 / (w * h * 32 ** 2))); return {width: w * units * 32, height: h * units * 32}; } -const failure = code => Object.assign(new Error(code || 'generation_failed'), {code: code || 'generation_failed'}); +const failure = (code, info = {}) => Object.assign(new Error(code || 'generation_failed'), { + code: code || 'generation_failed', report_id: info.report_id || null, stage: info.stage || '', +}); export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThis)) { let token = ''; @@ -41,7 +44,7 @@ export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThi async function request(path, options) { const response = await fetchImpl(path, options); const value = await response.json(); - if (!response.ok || value[p.error] && !value[p.phase] && !value.state) throw failure(value[p.error]); + if (!response.ok || value[p.error] && !value[p.phase] && !value.state) throw failure(value[p.error], value); return value; } async function post(action, value, retried = false) { @@ -73,6 +76,7 @@ export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThi const phase = value[p.phase]; return {...value, state: value.state || p.states[phase] || 'error', stage: value[p.stage] || (phase === 'starting' ? 'load' : ''), + waiting: phase === 'queued' ? value.queued_behind || 'image' : '', fitted: value.fitted || null, fraction: value[p.fraction], file: value[p.file], error: value[p.error], ...(phase === 'cancelled' ? {error: 'cancelled'} : {})}; }, @@ -100,11 +104,90 @@ export function imagePackMessage(info, t) { return t(`Download "${name}" in the launcher first, ${size}.`, `先在启动器里下载“${name}”,${size}。`); } export function imageGenError(code, info, t) { + const reasons = { + no_memory: ['Not enough GPU memory. Make the picture smaller in "Image size…" and try again.', + '显存不足。在“图像大小…”里缩小后再试。'], + busy_video: ['A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'], + busy: ['An image is already being generated. Try again when it finishes.', '已有一张图在生成,完成后再试。'], + model_unreadable: ['The image generation model files could not be read. Repair them in the launcher and try again.', + '图像生成模型的文件读不出来。在启动器里修复后再试。'], + device_unavailable: ['The graphics card is not available right now. Try again later.', '显卡暂时用不了,稍后再试。'], + worker_exit: ['Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'], + timeout: ['This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'], + cancelled: ['Image generation was cancelled.', '已取消图像生成。'], + invalid_request: ['The image settings are invalid. Check them and try again.', '图像生成参数不合法,请检查后重试。'], + invalid_media: ['The reference image cannot be read. Choose another image.', '参考图读不出来,请换一张图。'], + reload: ['The image service restarted. Try again.', '图像服务已重启,请重试。'], + generation_failed: ['Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'], + }; if (code === 'pack_missing') return imagePackUnavailableMessage(info, t); - if (code === 'busy_video') return t('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'); - if (code === 'no_memory') return t('Not enough GPU memory. Make the picture smaller in "Image size…" and try again.', - '显存不足。在“图像大小…”里缩小后再试。'); - return t('The image could not be generated. Try again.', '没有生成成功,请再试一次。'); + return t(...(Object.hasOwn(reasons, code) ? reasons[code] : reasons.generation_failed)); +} + +// Keep image failure copy here for editorial review. The submit card and its +// wording belong to report_issue.js, shared with video reports. +// Queue and size-fit notes, kept with the failure copy for editorial review. +export const QUEUE_LABELS = Object.freeze({ + image: ['Added to the queue. It starts when the previous one finishes.', '已加入队列,前面的完成后自动开始。'], + video: ['Queued. It starts when the video finishes.', '已排队,视频生成完后自动开始。'], +}); +export const fittedNote = (size, t) => t(`Not enough GPU memory. Made at ${size.width}\u00a0×\u00a0${size.height} instead.`, + `显存不足,已改为 ${size.width}\u00a0×\u00a0${size.height} 生成。`); +export const REPORT_LABELS = Object.freeze({ + download: ['Diagnostic report', '诊断报告'], + description: ['Redacted diagnostic report: hardware, settings and timings', '已脱敏的诊断报告:硬件、设置和耗时'], + preparing: ['Preparing…', '正在整理…'], + unavailable: ['Report unavailable · retry', '报告暂不可用 · 重试'], +}); + +// Shared by the editor and the other image entry points. Only the opaque report +// ID goes in the request; raw service messages are never shown in the page. +export function renderImageFailure(host, {error, report_id} = {}, t, + {productVersion = () => null, fetchApi = globalThis.fetch.bind(globalThis)} = {}) { + host.replaceChildren(); + if (!error || error === 'cancelled') return null; + const doc = host.ownerDocument || document; + const cssURL = new URL('./result_actions.css', import.meta.url).href; + if (!doc.querySelector(`link[href="${cssURL}"]`)) { + const css = doc.createElement('link'); css.rel = 'stylesheet'; css.href = cssURL; doc.head.append(css); + } + const card = doc.createElement('div'); card.className = 'fv-result-links'; + const reason = doc.createElement('div'); reason.className = 'fv-image-failure-reason'; + reason.setAttribute('role', 'status'); reason.textContent = imageGenError(error, null, t); + card.append(reason); host.append(card); + if (!report_id) return card; + const fetchReport = async () => { + const response = await fetchApi(`${IMAGE_GEN_PROTOCOL.root}/report/${encodeURIComponent(report_id)}`, {cache: 'no-store'}); + if (!response.ok) throw new Error('report_unavailable'); + return response.blob(); + }; + const version = () => typeof productVersion === 'function' ? productVersion() : productVersion; + const bar = doc.createElement('div'); bar.className = 'fv-actions'; + const anchor = doc.createElement('button'); anchor.type = 'button'; + anchor.className = 'fv-action fv-action-secondary fv-action-report'; + anchor.textContent = t(...REPORT_LABELS.download); anchor.title = t(...REPORT_LABELS.description); + anchor.onclick = async () => { + if (anchor.disabled) return; + const restoreFocus = doc.activeElement === anchor; + anchor.disabled = true; anchor.textContent = t(...REPORT_LABELS.preparing); + try { + await saveReport(await fetchReport()); + if (card.isConnected) showReportTip(bar, anchor, t, {mode: 'downloaded', + submit: () => reportIssue(fetchReport, version(), {save: false}), onHide: () => anchor.focus()}); + anchor.textContent = t(...REPORT_LABELS.download); + } catch { anchor.textContent = t(...REPORT_LABELS.unavailable); } + finally { + anchor.disabled = false; + if (restoreFocus && card.isConnected && doc.activeElement === doc.body) anchor.focus({preventScroll: true}); + } + }; + // Let native Enter/Space activate these buttons without invoking editor + // canvas shortcuts. Tab and the shared card's Escape handling still work. + card.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') event.stopPropagation(); + }); + bar.append(anchor); card.append(bar); + return card; } function imagePackUnavailableMessage(info, t) { return info?.[IMAGE_GEN_PROTOCOL.downloading] @@ -207,8 +290,11 @@ export function createImageGenSession({client, prepare, apply, onChange = () => schedule = setTimeout, unschedule = clearTimeout, visible = () => globalThis.document?.visibilityState !== 'hidden'}) { let epoch = 0, timer = null, infoTimer = null, disposed = false, checking = null; - let state = {info: null, checking: false, running: false, job: null, fraction: 0, stage: '', error: ''}; + let state = {info: null, checking: false, running: false, job: null, fraction: 0, stage: '', error: '', report_id: null, + waiting: '', fitted: null}; const emit = patch => { state = {...state, ...patch}; if (!disposed) onChange(state); }; + const failed = error => error.code === 'cancelled' ? {error: '', report_id: null, stage: ''} + : {error: error.code || 'generation_failed', report_id: error.report_id || null, stage: error.stage || ''}; const cancelJob = job => Promise.resolve().then(() => client.cancel(job)).catch(() => {}); function scheduleInfo() { unschedule(infoTimer); infoTimer = null; @@ -223,7 +309,7 @@ export function createImageGenSession({client, prepare, apply, onChange = () => emit({checking: true}); checking = (async () => { try { const info = await client.info(); if (!disposed) emit({info}); } - catch (error) { if (!disposed && !state.running) emit({error: error.code || error.message}); } + catch (error) { if (!disposed && !state.running) emit(failed(error)); } finally { checking = null; if (!disposed) { emit({checking: false}); scheduleInfo(); } } })(); return checking; @@ -231,7 +317,7 @@ export function createImageGenSession({client, prepare, apply, onChange = () => async function start(request) { if (state.running || disposed || !request.prompt.trim() || !state.info?.installed) return; const current = ++epoch; - emit({running: true, job: null, fraction: 0, stage: '', error: ''}); + emit({running: true, job: null, fraction: 0, stage: '', error: '', report_id: null, waiting: '', fitted: null}); const alive = () => current === epoch && !disposed; let withImages = false; async function poll(job, context) { @@ -239,17 +325,18 @@ export function createImageGenSession({client, prepare, apply, onChange = () => const row = await client.status(job); if (!alive()) return; const fraction = imageGenProgress(row.stage, row.fraction, withImages); - emit({fraction: Math.max(state.fraction, fraction), stage: row.stage || ''}); + emit({fraction: Math.max(state.fraction, fraction), stage: row.stage || '', waiting: row.waiting || '', + fitted: row.fitted || state.fitted}); if (row.state === 'done') { if (!row.file) throw failure(); await apply(row, context, alive); - if (alive()) emit({running: false, job: null, fraction: 1, stage: ''}); - } else if (row.state === 'error') throw failure(row.error); + if (alive()) emit({running: false, job: null, fraction: 1, stage: '', waiting: ''}); + } else if (row.state === 'error') throw failure(row.error, row); else timer = schedule(() => { timer = null; void poll(job, context); }, IMAGE_GEN_POLL_MS); } catch (error) { if (alive()) { void cancelJob(job); - emit({running: false, job: null, stage: '', error: error.code || error.message}); + emit({running: false, job: null, waiting: '', ...failed(error)}); } } } @@ -257,27 +344,29 @@ export function createImageGenSession({client, prepare, apply, onChange = () => const {context, images = []} = await prepare(request, alive); if (!alive()) return; withImages = images.length > 0; - const {job} = await client.generate({prompt: request.prompt, width: context.width, height: context.height, images}); - if (!job) throw failure(); + const row = await client.generate({prompt: request.prompt, width: context.width, height: context.height, images}); + const {job} = row; + if (!job) throw failure(row.error, row); + if (row.fitted) emit({fitted: row.fitted}); if (!alive()) { await cancelJob(job); return; } emit({job}); timer = schedule(() => { timer = null; void poll(job, context); }, IMAGE_GEN_POLL_MS); } catch (error) { - if (alive()) emit({running: false, job: null, stage: '', error: error.code || error.message}); + if (alive()) emit({running: false, job: null, ...failed(error)}); } } function cancel() { ++epoch; unschedule(timer); timer = null; const job = state.job; - emit({running: false, job: null, stage: '', fraction: 0, error: ''}); + emit({running: false, job: null, stage: '', fraction: 0, error: '', report_id: null, waiting: '', fitted: null}); return job ? cancelJob(job) : Promise.resolve(); } - return {check, start, cancel, clearError: () => emit({error: ''}), state: () => state, + return {check, start, cancel, clearError: () => emit({error: '', report_id: null}), state: () => state, dispose() { disposed = true; unschedule(infoTimer); infoTimer = null; return cancel(); }}; } export function createImageGeneration({client, renderer, t, getDoc, getSelection, commit, el, button, segmented, - library = [], resolve = file => file, icon = () => '', onRatioChange}) { + library = [], resolve = file => file, icon = () => '', onRatioChange, productVersion, fetchApi}) { let mode = 'edit', prompt = '', mounted = null, references = []; let promptRequired = false, attentionTimer = null; const copies = new Map(); @@ -395,13 +484,25 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection action.title = !state.running && !prompt.trim() ? t('Write a short description first', '请先写一句画面描述') : ''; action.hidden = !!missing; const percent = Math.floor(state.fraction * 100) + '%'; - note.textContent = missing ? imagePackUnavailableMessage(state.info, t) - : promptRequired ? t('Write a short description first.', '请先写一句画面描述。') - : state.error ? imageGenError(state.error, state.info, t) - : state.running ? (state.stage === 'load' ? t('Loading the model', '加载模型') + ' ' : '') + percent : ''; + if (state.error && !promptRequired) { + const failureKey = `${state.error}:${state.report_id || ''}`; + if (mounted.failureKey !== failureKey) { + renderImageFailure(note, state, t, {productVersion, fetchApi}); + mounted.failureKey = failureKey; + } + } else { + mounted.failureKey = null; + note.textContent = missing ? imagePackUnavailableMessage(state.info, t) + : promptRequired ? t('Write a short description first.', '请先写一句画面描述。') + : state.running && state.waiting ? t(...(QUEUE_LABELS[state.waiting] || QUEUE_LABELS.image)) + : state.running ? (state.stage === 'load' ? t('Loading the model', '加载模型') + ' ' : '') + percent + : state.fitted ? fittedNote(state.fitted, t) : ''; + } note.hidden = !note.textContent; - bar.dataset.running = String(state.running); bar.dataset.message = String(!!missing || promptRequired || !!state.error); - meter.hidden = !state.running; meter.setAttribute('aria-valuenow', String(Math.floor(state.fraction * 100))); + bar.dataset.running = String(state.running); + bar.dataset.message = String(!!missing || promptRequired || !!state.error || !!note.textContent); + bar.dataset.failed = String(!!state.error && !promptRequired); + meter.hidden = !state.running || !!state.waiting; meter.setAttribute('aria-valuenow', String(Math.floor(state.fraction * 100))); fill.style.width = state.fraction * 100 + '%'; } function mount(bar) { @@ -426,7 +527,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection const box = el('div', null, 'fv-ie-gen-box'); const input = el('textarea', null, 'fv-ie-gen-prompt'); input.rows = 1; input.maxLength = 2000; input.value = prompt; box.append(input); field.append(refs, box); - const note = el('span', '', 'fv-ie-opt-note fv-ie-gen-note'); note.setAttribute('role', 'status'); + const note = el('div', '', 'fv-ie-opt-note fv-ie-gen-note'); note.setAttribute('role', 'status'); const action = button(t('Generate', '生成'), null, () => { if (session.state().running) void session.cancel(); else void start(); }, 'fv-ie-button fv-ie-primary'); From a5c1a4c4328dc4be115cf99ddca6c4d6120c22eb Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 10:25:13 +0000 Subject: [PATCH 15/53] Settings show validation, avoid overlapping saves, and show measurement progress --- web/compatibility.js | 27 +++++++++++++++++++++++---- web/setup.js | 23 ++++++++++++++++++----- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/web/compatibility.js b/web/compatibility.js index 24ebf3f1..78fc7fa1 100644 --- a/web/compatibility.js +++ b/web/compatibility.js @@ -48,6 +48,7 @@ export async function openCompatibility() { if (current?.open) { current.focus(); return; } const panel = dialog(t('Compatibility', '兼容性')); current = panel; const status = element('p', t('Loading settings…', '正在读取设置…')); status.className = 'fv-muted'; + status.setAttribute('role', 'status'); panel.actions.before(status); show(panel); try { @@ -70,14 +71,32 @@ export async function openCompatibility() { status.before(name, slider, autoLabel, note); const render = () => { name.textContent = value.levels[Number(slider.value)][cn ? 'zh' : 'en']; }; render(); slider.oninput = () => { render(); if (Number(slider.value) === 0) automatic.checked = false; }; - button(panel, t('Cancel', '取消'), () => panel.close()); - button(panel, t('Save', '保存'), async () => { + let saving = false; + panel.addEventListener('cancel', event => { if (saving) event.preventDefault(); }); + const close = panel.querySelector('.fv-close'); + const cancel = button(panel, t('Cancel', '取消'), () => panel.close()); + const save = button(panel, t('Save', '保存'), async () => { + if (saving) return; + saving = true; + for (const control of [slider, automatic, save, cancel, close]) control.disabled = true; + save.textContent = t('Saving…', '正在保存…'); + panel.setAttribute('aria-busy', 'true'); + panel.tabIndex = -1; panel.focus(); + status.textContent = ''; status.className = 'fv-muted'; try { const saved = await request('save', {level: Number(slider.value), automatic: automatic.checked}); - if (!saved.available) { slider.disabled = true; automatic.disabled = true; status.textContent = unavailable(); return; } + if (!saved.available) { status.className = 'fv-failure'; status.textContent = unavailable(); return; } panel.close(); } - catch (error) { status.textContent = error.message; } + catch (error) { status.className = 'fv-failure'; status.textContent = t('Save failed: ', '保存失败:') + error.message; } + finally { + saving = false; + for (const control of [slider, automatic, save, cancel, close]) control.disabled = false; + save.textContent = t('Save', '保存'); + panel.setAttribute('aria-busy', 'false'); + panel.removeAttribute('tabindex'); + if (panel.open) save.focus(); + } }, true); } catch (error) { status.textContent = error.message; diff --git a/web/setup.js b/web/setup.js index 6003743f..d5c09a41 100644 --- a/web/setup.js +++ b/web/setup.js @@ -109,7 +109,10 @@ export async function openSetup() { const reserveHelp = note(t('Keep this much additional VRAM free. Applies to new requests; current usage is already accounted for.', '额外保留的空闲显存。对后续请求生效;其他程序当前的占用已单独计入。')); resources.append(autoLabel, reserveLabel, reserveHelp, saveReserve, reserveStatus); advanced.append(resources); - reserveAuto.onchange = () => { reserve.disabled = reserveAuto.checked; }; + const clearReserveStatus = () => { reserveStatus.textContent = ''; reserveStatus.className = 'fv-muted'; }; + reserveStatus.setAttribute('aria-live', 'polite'); + reserve.oninput = clearReserveStatus; + reserveAuto.onchange = () => { reserve.disabled = reserveAuto.checked; clearReserveStatus(); }; // Placement calibration. A measurement, not a setting: it changes nothing // about this installation, it reports what this machine measures so the // choices that are currently limited to one platform can be settled. @@ -192,20 +195,29 @@ export async function openSetup() { render(await request('calibrate', {repeats: Number(calibrateRepeats.value)})); poll(); calibration.open = true; - calibrateStatus.textContent = t('Running. Progress is below; closing this panel keeps it running.', - '正在运行。进度见下方;关闭面板不会中断。'); + calibrateStatus.textContent = t('Running. Closing this panel keeps it running.', + '正在运行,关闭面板不会中断。'); + installation.open = true; + cancel.scrollIntoView({block: 'center'}); } catch (error) { calibrateStatus.className = 'fv-failure'; calibrateStatus.textContent = failureText(error.message); } finally { calibrateButton.disabled = false; } }; saveReserve.onclick = async () => { - if (!reserveAuto.checked && (!reserve.value || !reserve.checkValidity())) { reserve.reportValidity(); return; } + clearReserveStatus(); + if (!reserveAuto.checked && !reserve.value) { + reserveStatus.className = 'fv-failure'; + reserveStatus.textContent = t('Enter a value.', '请输入数值。'); + reserve.focus(); + return; + } + if (!reserveAuto.checked && !reserve.checkValidity()) { reserve.reportValidity(); return; } saveReserve.disabled = true; try { showResources(await request('resources', {[reserveField]: reserveAuto.checked ? null : Number(reserve.value)})); reserveStatus.textContent = t('Saved · applies to the next request', '已保存 · 下次生成生效'); - } catch (error) { reserveStatus.textContent = failureText(error.message); } + } catch (error) { reserveStatus.className = 'fv-failure'; reserveStatus.textContent = failureText(error.message); } finally { saveReserve.disabled = false; } }; function showDownloads(value) { @@ -314,6 +326,7 @@ export async function openSetup() { } function render(task) { busy = Boolean(task.busy); ready = Boolean(task.ready) || ready && task.status !== 'failed'; + cancel.textContent = task.action === 'calibrate' ? t('Stop', '停止') : t('Stop setup', '停止安装'); inputs.hidden = busy; plan.hidden = busy; if (task.plan_id && task.plan_id !== lastPlanId) { lastPlanId = task.plan_id; planErrors = Boolean(task.plan?.errors?.length); planStale = false; From a44140caa45cc88b75cf52516733aa9f69b8812d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 10:37:38 +0000 Subject: [PATCH 16/53] Release page links the ModelScope offline folder next to Quark --- scripts/publish_release.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/publish_release.py b/scripts/publish_release.py index 80de9b8d..c1b05c86 100644 --- a/scripts/publish_release.py +++ b/scripts/publish_release.py @@ -117,11 +117,15 @@ def checksums(builds): *('%s %s' % (builds[c]['sha256'], builds[c]['path'].name) for c in PLATFORMS), '```', '', ''] +# The same offline packages, mirrored to ModelScope for downloads from China (anonymous folder page). +OFFLINE_MODELSCOPE = 'https://modelscope.cn/models/BowenXue/FreeVideo-releases/tree/master/offline' + + def stable_body(builds, tag): # Visitors come to download: the files come first, then what changed. rows = [LOGO, '## Download / 下载', ''] + downloads(PAGE + '/releases/download/' + tag + '/') rows += ['**Windows:** run FreeVideo.exe, choose an existing ComfyUI folder or install a new one, then click ' - '**Install & launch**. Offline packages are on [Quark](https://pan.quark.cn/s/c51235b84618).', + '**Install & launch**. Offline packages are on [Quark](https://pan.quark.cn/s/c51235b84618) and [ModelScope](%s).' % OFFLINE_MODELSCOPE, '', '**macOS:** open the DMG and drag FreeVideo.app into Applications. If macOS asks you to confirm the first ' 'launch, see the [first-open steps](%s/blob/main/docs/Mac.md#first-open).' % PAGE, @@ -130,7 +134,7 @@ def stable_body(builds, tag): 'Windows. On a server or from a terminal, see [Linux](%s/blob/main/docs/Linux.md).' % PAGE, '', '**Windows:** 运行 FreeVideo.exe,选择已有的 ComfyUI 或安装新的 ComfyUI,点击 **安装并启动**。' - '离线包见[夸克网盘](https://pan.quark.cn/s/c51235b84618)。', + '离线包见[夸克网盘](https://pan.quark.cn/s/c51235b84618)或[魔搭](%s)。' % OFFLINE_MODELSCOPE, '', '**macOS:** 打开 DMG,将 FreeVideo.app 拖入「应用程序」。首次打开如需确认,请参考' '[首次打开步骤](%s/blob/main/docs/Mac.zh-CN.md#首次打开)。' % PAGE, From 58d8cbe27c5166a06eccd512f38b240b141af5f4 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 10:38:02 +0000 Subject: [PATCH 17/53] Chat keeps drafts, recovers actions without duplicate sends, and says what did not happen --- freevideo_engine/agent/routes.py | 7 +- freevideo_engine/agent/schema.py | 1 + web/autopilot.js | 2 + web/autopilot_chat.css | 6 + web/autopilot_chat.js | 260 +++++++++++++++++++++++-------- 5 files changed, 209 insertions(+), 67 deletions(-) diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index f9736701..26a3577e 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -804,6 +804,11 @@ def chat_fold(store, sid): fold['items'].append(dict(base, role='user' if row['by'] == 'user' else 'agent', text=row['text'])) if row['by'] == 'user': fold['said'] = row['text'] + if row.get('mid'): + fold['sent'][row['mid']] = row['seq'] + fold['ids'][row['seq']] = row['mid'] + while len(fold['sent']) > 500: + fold['ids'].pop(fold['sent'].pop(next(iter(fold['sent']))), None) elif row['type'] == 'step' and row['state'] == 'done': job = (row.get('refs') or {}).get('job') if job and job not in fold['jobs']: @@ -916,7 +921,7 @@ def write(): if store.stopped(sid): # Speaking again continues a conversation the user stopped earlier. store.append(sid, dict(type='control', action='resumed'), by='user') - row = store.append(sid, dict(type='message', text=value['text']), by='user') + row = store.append(sid, dict(type='message', text=value['text'], mid=value['id']), by='user') fold['sent'][value['id']] = row['seq'] fold['ids'][row['seq']] = value['id'] while len(fold['sent']) > 500: diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 938a9ada..a4e93ac1 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -743,6 +743,7 @@ def session_event(*, stored=False, agent_only=False): obj(dict(type={'const': 'plan'}, shots=array(obj(dict(text=text, seconds=number(0)), ('text',)), 1, 20000), total_seconds=number(0)), ('type', 'shots'))] if not agent_only: + variants[0]['properties']['mid'] = CHAT_ID from .errors import ERRORS variants += [obj(dict(type={'const': 'step'}, id=string(12, pattern=r'^st[0-9a-f]{10}$'), tool=string(200), summary=text, state=enum(('started', 'done', 'failed')), diff --git a/web/autopilot.js b/web/autopilot.js index ffca7b44..b4f20b77 100644 --- a/web/autopilot.js +++ b/web/autopilot.js @@ -67,6 +67,8 @@ export function openAutopilotView(node, {studio = null} = {}) { lamp.hidden = !ready; lamp.dataset.color = label.color; lampText.textContent = label.text; lamp.disabled = connected || !last.any; const job = talking ? last.current : null; now.hidden = !job; line.dataset.on = String(!!job); + halt.disabled = !!last.stopping; + halt.title = last.stopping ? t('Stopping', '正在停止') : t('Stop all', '全部停止'); halt.setAttribute('aria-label', halt.title); if (job) { const fraction = last.fraction || 0, running = job.status === 'running'; currentId = job.id; name.textContent = jobName(job, t); more.textContent = last.active > 1 ? `+${last.active - 1}` : ''; diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 10d32eb2..5d49dffa 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -115,3 +115,9 @@ @media(max-width:480px){.fv-chat-main{padding:0 14px 14px}.fv-chat-scroll{margin:0 -14px;padding:16px 14px 2px}.fv-chat-hello{padding-top:32px}.fv-chat-job{max-width:none;gap:12px}.fv-chat-confirm{max-width:none} .fv-chat-preview,.fv-chat-preview[data-shape=wide]{width:98px}.fv-chat-length{display:none}.fv-chat-percent{font-size:22px}.fv-studio.fv-autopilot .fv-chat-input textarea{font-size:16px}.fv-studio .fv-chat-down{right:14px;bottom:84px}} @media(prefers-reduced-motion:reduce){.fv-chat-water,.fv-chat-percent,.fv-chat-parts>i::after{transition:none}.fv-chat-water::after,.fv-chat-dots>i,.fv-chat-preview>img,.fv-chat-menu{animation:none}} + +/* Request errors use the card's existing actions and retry button. */ +.fv-chat-error{color:var(--fv-danger);font-size:13px;line-height:1.5;overflow-wrap:anywhere;margin:0} +.fv-chat-new-error{padding:0 var(--fv-chats-pad) 6px} +.fv-chat-me>.fv-chat-actions{margin-top:6px;flex-wrap:wrap;justify-content:flex-end} +.fv-chat-me .fv-chat-error{flex:1;min-width:0} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index eed61ee5..afe125b1 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -150,6 +150,30 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () let state = emptyChat(), pending = [], known = false, stopped = false, timer = null, busy = false, failures = 0; let conversations = emptyConversations(), selectedSession, selection = 0, reread = false, focusSession = null, creating = false; + // Only the reading position is persisted, scoped to this server; drafts stay in this open page. + let selectionKey = null; + try { + selectionKey = `freevideo.chat.selected:${new URL(api.apiURL('/freevideo/agent/chat'), location.href).href}`; + selectedSession = localStorage.getItem(selectionKey) || undefined; + } catch { /* Storage can be unavailable. Reading still works. */ } + function rememberSelection(session) { + try { if (selectionKey) { if (session) localStorage.setItem(selectionKey, session); else localStorage.removeItem(selectionKey); } } catch {} + } + const drafts = new Map(), unsaidSessions = new Set(); + let draftSession = selectedSession ?? null; + function saveDraft() { + if (drafts.get(draftSession)?.text !== input.value) drafts.set(draftSession, {text: input.value}); + return drafts.get(draftSession); + } + function showDraft(session) { + if (draftSession === session) return; + saveDraft(); draftSession = session; input.value = drafts.get(session)?.text || ''; grow(); + } + const reason = error => error?.message || t('Connection lost', '连接中断'); + async function requestError(response) { + const data = await response.json().catch(() => ({})); + return new Error(data.error?.message || data.error?.code || `HTTP ${response.status}`); + } // Work under way in another conversation while this one has none: {session, job, ids}. The page's header keeps it in sight. let away = null; let listBusy = false, listAgain = false, listAt = -Infinity; @@ -158,12 +182,13 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () sidebar.id = `fv-chat-conversations-${++chatNumber}`; sidebar.setAttribute('aria-label', t('Chats', '对话')); sidebar.hidden = true; const newChat = press('fv-chat-new', t('New chat', '新对话'), () => void startChat()); newChat.prepend(glyph('M12 5v14M5 12h14')); newChat.title = t('New chat', '新对话'); + const newError = el('p', 'fv-chat-error fv-chat-new-error'); newError.hidden = true; newError.setAttribute('role', 'alert'); const chats = press('fv-chat-chats', null, () => drawer(chats.getAttribute('aria-expanded') !== 'true')); const chatsDot = el('i', 'fv-chat-running'); chatsDot.setAttribute('aria-hidden', 'true'); chatsDot.hidden = true; chats.append(glyph('M4 5h16v12H9l-5 4V5M8 9h8M8 13h5'), chatsDot); chats.title = t('Chats', '对话'); chats.setAttribute('aria-label', chats.title); chats.setAttribute('aria-controls', sidebar.id); chats.setAttribute('aria-expanded', 'false'); - tools.hidden = true; tools.append(chats); main.prepend(tools); sidebar.append(newChat, list); element.prepend(sidebar); + tools.hidden = true; tools.append(chats); main.prepend(tools); sidebar.append(newChat, newError, list); element.prepend(sidebar); const listNodes = new Map(), narrow = () => window.matchMedia('(max-width: 720px)').matches; let menuState = null, changingMenu = false; const deleting = new Set(), deleted = new Set(); @@ -235,6 +260,15 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () menuState = conversationMenu(conversations, menuState, 'open', session); updateMenu(true); } function renderConversations() { + // Keep this page's unsent conversations reachable even though history only lists spoken ones. + const rows = conversations.conversations.filter(row => !row.temporary); + const listed = new Set(rows.map(row => row.id)); + for (const session of unsaidSessions) { + if (listed.has(session)) { unsaidSessions.delete(session); continue; } + if (!deleted.has(session) && (drafts.get(session)?.text.trim() || pending.some(entry => entry.session === session))) + rows.unshift({id: session, title: t('New chat', '新对话'), running: 0, temporary: true}); + } + conversations = {...conversations, conversations: rows}; const previous = menuState; const lostFocus = previous && inMenu(document.activeElement); menuState = conversationMenu(conversations, menuState); @@ -257,6 +291,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () more.addEventListener('focusout', leaveMenu); container.append(button, more); listNodes.set(row.id, node = {row: container, button, dot, title, more}); } + node.row.dataset.temporary = String(!!row.temporary); node.title.textContent = row.title; node.button.title = row.title; node.dot.hidden = row.running <= 0; node.more.setAttribute('aria-label', t(`More: ${row.title}`, `更多:${row.title}`)); node.more.hidden = !canDeleteConversation(row); @@ -280,6 +315,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (stopped) return; if (response.ok || response.status === 404 && data.error?.code === 'not_found') { const current = conversations.current === session; + unsaidSessions.delete(session); drafts.delete(session); pending = pending.filter(entry => entry.session !== session); + if (current) { rememberSelection(null); draftSession = null; } deleted.add(session); conversations = removeConversation(conversations, session, data.here); if (menuState?.session === session) closeMenu(); if (current) { @@ -304,9 +341,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } function chooseChat(session, focus = false) { closeMenu(); + showDraft(session); rememberSelection(session); selectedSession = session; selection++; pinned = true; away = null; conversations = selectConversation(conversations, session); renderConversations(); - input.value = ''; grow(); drawer(false); + grow(); drawer(false); focusSession = focus ? session : null; if (focus && session === state.session) input.focus(); else if (narrow()) chats.focus(); render(); void readConversations(true); void poll(); @@ -314,15 +352,18 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () async function startChat() { closeMenu(); if (creating || !active() || conversations.supported !== true) return; - creating = true; renderConversations(); render(); + creating = true; newError.hidden = true; renderConversations(); render(); try { const response = await agentPost(api, '/freevideo/agent/chat/new', {}); if (stopped) return; if (response.ok) { const data = await response.json(); - if (typeof data.session === 'string' && data.session) chooseChat(data.session, true); - } else if (response.status === 409) { state = {...state, agent: {connected: false, name: null}}; report(); } - } catch { /* Keep the current conversation available for another try. */ } + if (typeof data.session !== 'string' || !data.session) throw new Error(t('The reply was incomplete. Try again.', '回复不完整,请重试。')); + unsaidSessions.add(data.session); chooseChat(data.session, true); + } else throw await requestError(response); + } catch (error) { + if (!stopped) { newError.textContent = t('Could not start a new chat: ', '没有新建成功:') + reason(error); newError.hidden = false; } + } finally { creating = false; if (!stopped) { renderConversations(); render(); } } } async function readConversations(force = false) { @@ -340,7 +381,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // A list read started before a deletion must not put the deleted row back. conversations = {...conversations, conversations: conversations.conversations.filter(row => !deleted.has(row.id))}; renderConversations(); - if (conversations.supported === false && selectedSession !== undefined) { selectedSession = undefined; selection++; void poll(); } + if (conversations.supported === false && selectedSession !== undefined) { selectedSession = undefined; rememberSelection(null); selection++; void poll(); } } catch { /* List failures do not interrupt reading or sending. */ } finally { listBusy = false; } if (listAgain) { listAgain = false; void readConversations(true); } @@ -378,7 +419,9 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // Whether the reader is at the newest entry. It holds while the conversation is out of sight, which forgets where it was scrolled. let pinned = true; const closed = new AbortController(); - const nodes = new Map(), answers = new Map(), stopping = new Set(); + const nodes = new Map(), answers = new Map(), stopping = new Set(), stopErrors = new Map(), retrying = new Set(), confirming = new Set(), confirmErrors = new Map(); + let bulkStopping = false; + const cardKey = (session, id) => JSON.stringify([session, id]); // The fraction each job shows: the estimate, never backwards, short of 100% until the video is saved. const levels = new Map(); function fractionOf(job) { @@ -402,12 +445,52 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () return row ? row.seconds : null; } const active = () => !stopped && element.isConnected && !document.hidden && shown(); - const sendingAllowed = () => !creating && (!selectedSession || selectedSession === state.session) && canSend(state); + const inSelectedChat = () => !creating && (!selectedSession || selectedSession === state.session); + const sendingAllowed = () => inSelectedChat() && canSend(state); + + const draftSending = () => pending.some(entry => entry.sending && entry.draft && entry.draft === drafts.get(draftSession)); + async function cancelJob(job, session) { + if (stopping.has(job.id)) return; + stopping.add(job.id); stopErrors.delete(job.id); render(); report(); + try { + const response = await agentPost(api, '/freevideo/agent/activity/cancel', {id: job.id}); + if (!response.ok) throw await requestError(response); + } catch (error) { + stopping.delete(job.id); + stopErrors.set(job.id, {session, text: t('Could not stop "' + jobName(job, t) + '": ', '没有停止“' + jobName(job, t) + '”:') + reason(error)}); + } + if (!stopped) { render(); report(); void poll(); } + } + async function retryJob(key, text) { + if (retrying.has(key) || !sendingAllowed()) return; + retrying.add(key); render(); + try { await send(text); } + finally { retrying.delete(key); if (!stopped) render(); } + } // ---- one entry of the conversation each ---- function message(role) { const item = el('li', role === 'user' ? 'fv-chat-me' : 'fv-chat-ai'), text = el('p'); item.append(text); - return {element: item, update(entry) { text.textContent = entry.text; if (role === 'user') item.dataset.taken = String(entry.taken !== false); }}; + let status = null, error = null, retry = null; + return {element: item, update(entry) { + text.textContent = entry.text; + if (role !== 'user') return; + item.dataset.taken = String(entry.taken !== false); + item.dataset.failed = String(!!entry.error); + if (entry.error || entry.sending && entry.retried) { + if (!status) { + status = el('div', 'fv-chat-actions'); + error = el('span', 'fv-chat-error'); error.setAttribute('role', 'alert'); + retry = press('fv-chat-retry', t('Retry', '重试'), () => { + const original = pending.find(value => value.id === entry.id); + if (original) void send(original.text, original); + }); + status.append(error, retry); item.append(status); + } + error.textContent = entry.error || ''; error.hidden = !entry.error; + retry.disabled = entry.sending || !sendingAllowed(); + } else if (status) { status.remove(); status = null; } + }}; } function preview(job) { const box = el('div', 'fv-chat-preview'); @@ -421,12 +504,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const middle = el('div', 'fv-chat-where'), parts = el('div', 'fv-chat-parts'), line = el('p', 'fv-chat-line'), part = el('span'), step = el('span'); line.append(part, step); middle.append(parts, line); const foot = el('div', 'fv-chat-actions'), left = el('span', 'fv-chat-left'); - const stop = press('fv-chat-stop', null, async () => { - stopping.add(job.id); render(); - try { await agentPost(api, '/freevideo/agent/activity/cancel', {id: job.id}); } catch { stopping.delete(job.id); } - poll(); - }); - foot.append(left, stop); info.append(head, middle, foot); item.replaceChildren(box, info); + const session = state.session; + const stop = press('fv-chat-stop', null, () => void cancelJob(job, session)); + const error = el('p', 'fv-chat-error'); error.setAttribute('role', 'alert'); + foot.append(left, stop); info.append(head, middle, foot, error); item.replaceChildren(box, info); const said = {}; // The number rides on the water, and goes under the line once there is no room left above it. const level = next => { @@ -451,6 +532,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const where = cardLine(said, next, t); part.textContent = cells.length ? t(`Part ${block.index} / ${block.total}`, `第 ${block.index} / ${block.total} 段`) : where; step.textContent = cells.length ? where : ''; + error.textContent = stopErrors.get(next.id)?.text || ''; error.hidden = !error.textContent; const going = stopping.has(next.id); stop.disabled = going; stop.textContent = going ? t('Stopping', '正在停止') : t('Stop', '停止'); stop.setAttribute('aria-label', t('Stop ', '停止 ') + jobName(next, t)); @@ -502,7 +584,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () function jobCard() { const item = el('li', 'fv-chat-job'); let mode = null, apply = null; - return {element: item, update(job, {latest, retry}) { + return {element: item, update(job, {latest, retry, key}) { // Jobs without a picture (a check, a prompt) are one quiet line while they run, and nothing after. // A picture takes seconds: the same quiet line while it is made, then the picture. const card = VIDEOS.includes(job.kind || 'generate') || (job.kind === 'image' && !WORKING.includes(job.status)); @@ -511,7 +593,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () : WORKING.includes(job.status) ? 'quiet' : 'none'; if (next !== mode) { mode = next; item.dataset.job = job.id; item.dataset.mode = next.split(':')[0]; item.hidden = next === 'none'; - if (next !== 'working') stopping.delete(job.id); + if (next !== 'working') { stopping.delete(job.id); stopErrors.delete(job.id); } if (next === 'working') apply = working(item, job); else if (next.startsWith('done')) apply = done(item, job); else if (next === 'quiet') { item.replaceChildren(el('span', 'fv-chat-dots'), el('span', null, t(...(QUIET[job.kind] || ['Working', '进行中'])))); apply = null; } @@ -528,30 +610,39 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } else { item.replaceChildren(); apply = null; } } apply?.(job, latest); - const again = item.querySelector('.fv-chat-retry'); if (again) again.disabled = !sendingAllowed(); + const again = item.querySelector('.fv-chat-retry'); if (again) again.disabled = !sendingAllowed() || retrying.has(key); }}; } function confirmCard() { const item = el('li', 'fv-chat-confirm'); let shownState = null; return {element: item, update(entry) { - const value = answers.get(entry.id) || state.confirms[entry.id] || entry.state || 'asked'; - if (value === shownState) return; - shownState = value; item.dataset.state = value === 'asked' ? 'asked' : 'answered'; + const session = state.session, key = cardKey(session, entry.id); + const value = answers.get(key) || state.confirms[entry.id] || entry.state || 'asked'; + const disabled = confirming.has(key) || !inSelectedChat() || state.agent.connected !== true, error = confirmErrors.get(key) || ''; + const signature = JSON.stringify([value, disabled, error]); + if (signature === shownState) return; + shownState = signature; item.dataset.state = value === 'asked' ? 'asked' : 'answered'; if (value !== 'asked') { item.replaceChildren(el('span', null, value === 'approved' ? t('Started', '已开始') : t('Not now', '先不拍'))); return; } const answer = approve => async () => { + if (confirming.has(key) || !inSelectedChat() || state.agent.connected !== true || state.session !== session) return; item.querySelectorAll('button').forEach(button => { button.disabled = true; }); + confirming.add(key); confirmErrors.delete(key); render(); try { - const response = await agentPost(api, '/freevideo/agent/chat/confirm', {id: entry.id, approve}); - if (response.ok) answers.set(entry.id, (await response.json()).state || (approve ? 'approved' : 'declined')); - } catch { /* The card stays as asked. */ } - shownState = null; render(); poll(); + const response = await agentPost(api, '/freevideo/agent/chat/confirm', {id: entry.id, approve, session}); + if (!response.ok) throw await requestError(response); + answers.set(key, (await response.json()).state || (approve ? 'approved' : 'declined')); + } catch (error) { confirmErrors.set(key, t('Could not start shooting: ', '没有开始拍摄:') + reason(error)); } + finally { confirming.delete(key); } + if (!stopped) { render(); void poll(); } }; const buttons = el('div', 'fv-chat-actions'); buttons.append(press('fv-chat-yes fv-primary', t('Start', '开始'), answer(true)), press('fv-chat-no', t('Not now', '先不'), answer(false))); + buttons.querySelectorAll('button').forEach(button => { button.disabled = disabled; }); const question = el('p', null, entry.question); const estimate = el('p', 'fv-chat-meta', entry.estimate_seconds > 0 ? about(entry.estimate_seconds, t) : ''); estimate.hidden = !estimate.textContent; item.replaceChildren(question, estimate, buttons); + if (error) { const notice = el('p', 'fv-chat-error', error); notice.setAttribute('role', 'alert'); item.append(notice); } }}; } // Before anything is said: what the agent can do, and sentences that send themselves when clicked. @@ -573,15 +664,17 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () item.setAttribute('role', 'status'); item.setAttribute('aria-label', t('Thinking', '正在思考')); item.append(mark); return {element: item, update() {}}; } - const MAKE = {hello, examples, dots, user: () => message('user'), agent: () => message('agent'), job: jobCard, confirm: confirmCard}; + const MAKE = {error: () => { const element = el('li', 'fv-chat-error'); element.setAttribute('role', 'alert'); return {element, update(text) { element.textContent = text; }}; }, hello, examples, dots, user: () => message('user'), agent: () => message('agent'), job: jobCard, confirm: confirmCard}; function render(follow = false) { const entries = chatEntries(state), ids = new Set(entries.map(entry => entry.id).filter(Boolean)); - pending = pending.filter(entry => !ids.has(entry.id)); - const said = entries.some(entry => entry.type !== 'message' || entry.role === 'user') || pending.length > 0; + for (const entry of pending) if (entry.session === state.session && ids.has(entry.id)) acknowledge(entry); + pending = pending.filter(entry => entry.sending || entry.session !== state.session || !ids.has(entry.id)); + const localPending = pending.filter(entry => entry.session === state.session && !ids.has(entry.id)); + const said = entries.some(entry => entry.type !== 'message' || entry.role === 'user') || localPending.length > 0; const finished = entries.filter(entry => entry.type === 'job' && state.jobs[entry.job]?.status === 'succeeded' && state.jobs[entry.job].work).at(-1); const wanted = []; - if (!entries.length && !pending.length) wanted.push(['hello', 'hello', null]); + if (!entries.length && !localPending.length) wanted.push(['hello', 'hello', null]); let lastUser = null; for (const entry of entries) { if (entry.type === 'message') { @@ -590,14 +683,16 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } else if (entry.type === 'job') { const job = state.jobs[entry.job]; if (!job) continue; const text = lastUser?.text; - wanted.push([`s:${entry.seq}`, 'job', job, {latest: entry === finished, retry: text ? () => send(text) : null}]); + const key = cardKey(state.session, job.id); + wanted.push([`s:${entry.seq}`, 'job', job, {latest: entry === finished, key, retry: text ? () => retryJob(key, text) : null}]); } else wanted.push([`s:${entry.seq}`, 'confirm', entry]); } - for (const entry of pending) wanted.push([`m:${entry.id}`, 'user', {...entry, taken: false}]); + for (const entry of localPending) wanted.push([`m:${entry.id}`, 'user', {...entry, taken: false}]); if (!said) wanted.push(['examples', 'examples', null]); // The agent has the sentence, or is waiting for one, and has not answered yet. - const tail = pending.at(-1) || entries.at(-1); - if (tail?.type !== 'job' && tail?.type !== 'confirm' && (pending.length || tail?.role === 'user') && state.agent.connected === true + for (const [id, error] of stopErrors) if (error.session !== state.session) wanted.push([`stop:${id}`, 'error', error.text]); + const tail = localPending.at(-1) || entries.at(-1); + if (tail?.type !== 'job' && tail?.type !== 'confirm' && (localPending.some(entry => !entry.error) || !tail?.error && tail?.role === 'user') && state.agent.connected === true && (state.agent.listening !== false || tail.taken === true)) wanted.push(['dots', 'dots', null]); const stick = follow || pinned; @@ -612,7 +707,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const sendable = sendingAllowed(); input.disabled = state.agent.connected !== true || creating || !!selectedSession && selectedSession !== state.session; input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to keep listening', '回到 Agent 那边让它继续') : t('Message the agent…', '对 Agent 说…'); - sendButton.disabled = !sendable || !input.value.trim(); + sendButton.disabled = !sendable || draftSending() || !input.value.trim(); if (focusSession === state.session && focusSession && !input.disabled && active()) { focusSession = null; input.focus(); } if (stick) { pinned = true; scroller.scrollTop = scroller.scrollHeight; down.hidden = true; } else if (log.children.length > before) down.hidden = false; @@ -633,7 +728,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (!response.ok) { away = null; return; } const data = await response.json(), jobs = Object.values(data.jobs || {}).filter(job => WORKING.includes(job?.status)); const job = data.jobs?.[data.current] || jobs[0]; - away = job ? {session: row.id, job, ids: jobs.map(job => job.id)} : null; + away = job ? {session: row.id, job, jobs: data.jobs, ids: jobs.map(job => job.id)} : null; } catch { /* The last reading stays until the next one. */ } } let reportedJob = null; @@ -642,38 +737,51 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // A job that starts or ends changes a dot in the list, so the list is read again right away. if ((current?.id || null) !== reportedJob) { reportedJob = current?.id || null; void readConversations(true); } changed({known, agent: state.agent, current, fraction: current ? fractionOf(current) : 0, left: current ? leftOf(current) : null, active: state.active, - any: state.items.size > 0 || pending.length > 0}); + stopping: bulkStopping, any: state.items.size > 0 || pending.some(entry => entry.session === state.session)}); } - async function send(text) { + function acknowledge(entry) { + entry.acknowledged = true; entry.error = null; + unsaidSessions.delete(entry.session); + if (entry.draft && drafts.get(entry.session) === entry.draft) { + drafts.delete(entry.session); + if (draftSession === entry.session) { input.value = ''; grow(); } + } + } + async function send(text, original = null, draft = null) { text = String(text).trim(); - if (!text || !sendingAllowed()) return false; - const sentSelection = selection; - const entry = {id: newId(), text}; pending.push(entry); render(true); report(); - let response = null; - const body = conversations.supported === true && state.session ? {...entry, session: state.session} : entry; - try { response = await agentPost(api, '/freevideo/agent/chat', body); } catch { /* Handled below as not sent. */ } - if (stopped) return false; - if (response?.ok) { + original ||= pending.find(entry => entry.session === (state.session ?? null) && entry.text === text && (entry.error || entry.sending)); + if (!text || !sendingAllowed() || original?.sending || original && original.session !== state.session) return false; + const entry = original || {id: newId(), text, session: state.session ?? null, draft}; + if (draft) entry.draft = draft; + if (!original) pending.push(entry); + entry.sending = true; entry.retried = !!original; entry.error = null; render(true); report(); + try { + const body = {id: entry.id, text: entry.text}; + if (conversations.supported === true && entry.session) body.session = entry.session; + const response = await agentPost(api, '/freevideo/agent/chat', body); + if (!response.ok) throw await requestError(response); const value = await response.json().catch(() => ({})); - if (sentSelection === selection) { if (value.agent) state.agent = value.agent; void poll(); } - void readConversations(true); return true; + acknowledge(entry); + if (!entry.session && value.session) entry.session = value.session; + if (!stopped) { void poll(); void readConversations(true); } + return true; + } catch (error) { + if (entry.acknowledged) return true; + entry.error = t('Could not send: ', '没有发送成功:') + reason(error); + return false; + } finally { + entry.sending = false; + if (!stopped) { render(); renderConversations(); report(); } } - if (sentSelection !== selection) return false; - // Not sent: the sentence goes back to the box. An agent that was never connected leads back to the card. - pending = pending.filter(item => item !== entry); - if (!input.value) { input.value = text; grow(); } - if (response?.status === 409) state = {...state, agent: {connected: false, name: null}}; - render(); report(); - return false; } function grow() { input.style.height = 'auto'; // At most five lines; the box scrolls after that. - const line = parseFloat(getComputedStyle(input).lineHeight) || 22; + const line = (typeof getComputedStyle === 'function' && parseFloat(getComputedStyle(input).lineHeight)) || 22; input.style.height = `${Math.min(input.scrollHeight, Math.round(line * 5) + 14)}px`; - sendButton.disabled = !sendingAllowed() || !input.value.trim(); + sendButton.disabled = !sendingAllowed() || draftSending() || !input.value.trim(); } - input.addEventListener('input', grow); + input.addEventListener('input', () => { saveDraft(); grow(); if (unsaidSessions.has(draftSession)) renderConversations(); }); // Enter sends, Shift+Enter starts a new line; Enter that confirms an input-method candidate does neither. input.addEventListener('keydown', event => { if (event.key !== 'Enter' || event.shiftKey || event.isComposing || event.keyCode === 229) return; @@ -682,13 +790,15 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () form.addEventListener('submit', event => { event.preventDefault(); const text = input.value; - if (!text.trim() || !sendingAllowed()) return; - input.value = ''; grow(); void send(text); + const draft = saveDraft(); + if (!text.trim() || !sendingAllowed() || draftSending()) return; + const failed = pending.find(entry => entry.session === state.session && entry.draft === draft && entry.error); + void send(text, failed, draft); }); // Both a server-side session change and a list selection reset the same reader. Job clocks stay keyed by job id. function resetChat() { - state = emptyChat(); pending = []; answers.clear(); nodes.clear(); + state = emptyChat(); nodes.clear(); log.querySelectorAll('video').forEach(video => video.pause()); log.replaceChildren(); pinned = true; } async function poll() { @@ -715,15 +825,31 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // Another conversation began: forget this one and read the new one whole. else if (after && (data.session ?? null) !== state.session) { resetChat(); again = true; } else { + // The first send can create the session before its HTTP response reaches the page. + // Adopt the page's unassigned draft/outbox when that session first appears in a read. + if (state.session == null && data.session && !requested) { + for (const entry of pending) if (entry.session == null) entry.session = data.session; + if (draftSession === null) { + const draft = saveDraft(); + // Text entered during deletion's fallback read joins the target's older draft; neither is dropped. + const older = drafts.get(data.session)?.text; + if (draft.text && older && older !== draft.text) draft.text = `${older}\n\n${draft.text}`; + if (draft.text || !older) drafts.set(data.session, draft); + drafts.delete(null); draftSession = data.session; + input.value = drafts.get(data.session)?.text || ''; grow(); + } + } if ((data.session ?? null) !== state.session) resetChat(); state = chatMerge(state, data); failures = 0; + if (state.session && !chatEntries(state).some(entry => entry.type === 'message' && entry.role === 'user')) unsaidSessions.add(state.session); + showDraft(state.session); rememberSelection(state.session); conversations = selectConversation(conversations, state.session); renderConversations(); await readAway(); if (stopped) return; if (readSelection !== selection) { away = null; again = true; } } } else if (response.status === 404 && requested) { - selectedSession = undefined; selection++; resetChat(); again = true; void readConversations(true); + selectedSession = undefined; rememberSelection(null); selection++; resetChat(); again = true; void readConversations(true); } else failures++; } catch { failures++; } finally { busy = false; } @@ -755,10 +881,12 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () else log.querySelector(`[data-job="${CSS.escape(id)}"]`)?.scrollIntoView({block: 'center', behavior: 'smooth'}); }, async stopAll() { - const ids = [...new Set([...Object.values(state.jobs).filter(job => WORKING.includes(job.status)).map(job => job.id), ...(away?.ids || [])])]; - ids.forEach(id => stopping.add(id)); render(); - await Promise.allSettled(ids.map(id => agentPost(api, '/freevideo/agent/activity/cancel', {id}))); - poll(); + if (bulkStopping) return; + const jobs = new Map(Object.values(state.jobs).filter(job => WORKING.includes(job.status)).map(job => [job.id, {job, session: state.session}])); + for (const id of away?.ids || []) if (!jobs.has(id)) jobs.set(id, {job: away.jobs[id], session: away.session}); + bulkStopping = true; report(); + try { await Promise.all([...jobs.values()].map(({job, session}) => cancelJob(job, session))); } + finally { bulkStopping = false; if (!stopped) { report(); void poll(); } } }, stop() { closeMenu(); stopped = true; closed.abort(); clearInterval(moving); clearTimeout(timer); timer = null; document.removeEventListener('visibilitychange', visible); document.removeEventListener('pointerdown', outside, true); From 947afc61426f0d998101ffded017555d8537e2a6 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:08:24 +0000 Subject: [PATCH 18/53] Product terms stay untranslated when the browser translates the page --- web/view_navigation.js | 2 ++ web/wordmark.js | 1 + 2 files changed, 3 insertions(+) diff --git a/web/view_navigation.js b/web/view_navigation.js index cf145e5b..1beee614 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -47,6 +47,8 @@ export function viewSwitch(view, onStudio, onNodes, onEditor, onAutopilot) { ['autopilot', t('Autopilot', 'AI 全自动'), onAutopilot]]) { const button = document.createElement('button'); button.type = 'button'; const text = document.createElement('span'); text.textContent = label; button.append(text); + // Browser translation must leave the product name as written. + if (id === 'autopilot') text.setAttribute('translate', 'no'); button.dataset.label = label; button.dataset.view = id; if (id === 'editor') button.dataset.fvGuide = 'longer'; button.setAttribute('aria-pressed', String(view === id)); button.onclick = action; diff --git a/web/wordmark.js b/web/wordmark.js index e9c2ad48..bf9251e7 100644 --- a/web/wordmark.js +++ b/web/wordmark.js @@ -9,6 +9,7 @@ export function wordmark() { image.className = 'fv-wordmark'; image.src = new URL('./assets/freevideo.svg', import.meta.url).href; image.alt = 'FreeVideo'; image.width = 2016; image.height = 337; + image.setAttribute('translate', 'no'); image.draggable = false; return image; } From 499c32756db634cbcc29232cfd760e4ee7c86872 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:09:11 +0000 Subject: [PATCH 19/53] Creation panel keeps settings, prompt and media after refresh, reopen, undo and redo --- scripts/test_freevideo_entry.mjs | 2 +- web/freevideo.js | 64 ++++++++-- web/launcher.js | 55 ++++++++- web/prompt_draft.js | 195 ++++++++++++++++++++++++++++--- web/studio.js | 33 ++++-- 5 files changed, 311 insertions(+), 38 deletions(-) diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index de10236b..b27a7be4 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -93,7 +93,7 @@ test('main browser entry registers both views and preserves node hooks', async t .replace("import { outputDownloadURL } from './output_download.js';", 'const {outputDownloadURL} = globalThis.__freevideoEntryTest;') .replace("import { shareButton } from './share.js';", 'const {shareButton} = globalThis.__freevideoEntryTest;') .replace("import { reportFileName } from './report_issue.js';", 'const {reportFileName} = globalThis.__freevideoEntryTest;') - .replace("import { rememberPromptDraft, savePromptDraft } from './prompt_draft.js';", 'const {rememberPromptDraft,savePromptDraft} = globalThis.__freevideoEntryTest;') + .replace("import { rememberPromptDraft, savePromptDraft, trackPromptDraft, watchPromptHistory, promptHistoryRestoring } from './prompt_draft.js';", 'const {rememberPromptDraft,savePromptDraft} = globalThis.__freevideoEntryTest; const trackPromptDraft = () => {}; const watchPromptHistory = () => {}; const promptHistoryRestoring = () => false;') .replace("import { cleanOptionalWidgets, cleanOptionalPrompt } from './optional_inputs.js';", 'const {cleanOptionalWidgets,cleanOptionalPrompt} = globalThis.__freevideoEntryTest;') .replace("import { regenerateResult, upscaleResult } from './studio_queue.js';", 'const {regenerateResult,upscaleResult} = globalThis.__freevideoEntryTest;') .replace("import { openImageEditor, icon } from './image_editor.js';", 'const {openImageEditor,icon} = globalThis.__freevideoEntryTest;') diff --git a/web/freevideo.js b/web/freevideo.js index 28c1d754..7f8d40e8 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -13,7 +13,7 @@ import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceIn import { outputDownloadURL } from './output_download.js'; import { shareButton } from './share.js'; import { reportFileName } from './report_issue.js'; -import { rememberPromptDraft, savePromptDraft } from './prompt_draft.js'; +import { rememberPromptDraft, savePromptDraft, trackPromptDraft, watchPromptHistory, promptHistoryRestoring } from './prompt_draft.js'; import { cleanOptionalWidgets, cleanOptionalPrompt } from './optional_inputs.js'; import { regenerateResult, upscaleResult } from './studio_queue.js'; import { openImageEditor, icon } from './image_editor.js'; @@ -569,6 +569,42 @@ function resultPanel(node) { } let configuringGraph = false; +let configuration = 0; +const watchedGraphs = new WeakSet(); +function captureWorkflow(graph) { + if (!graph || graph !== app.graph || promptHistoryRestoring(app)) return; + watchPromptHistory(api, app); + const tracker = app.extensionManager?.workflow?.activeWorkflow?.changeTracker; + // Current ComfyUI and older installations expose different names. + (tracker?.captureCanvasState || tracker?.checkState)?.call(tracker); +} +function trackCreationDraft(node) { + trackPromptDraft(node, () => captureWorkflow(node.graph), () => app.extensionManager?.workflow?.activeWorkflow?.path); +} +function watchCreationGraph(graph) { + if (!graph || watchedGraphs.has(graph)) return; + watchedGraphs.add(graph); + const changed = graph.on_change; + let queued = false; + graph.on_change = function (...args) { + const result = changed?.apply(this, args); + if (!configuringGraph && graph === app.graph && !queued) { + queued = true; + const revision = configuration; + queueMicrotask(() => { + queued = false; + if (configuringGraph || graph !== app.graph || revision !== configuration || promptHistoryRestoring(app)) return; + // graph.change() only redraws. Capture the finished edit, including + // properties and the asynchronous media/LoRA panel updates. + captureWorkflow(graph); + for (const node of graph._nodes || []) { + if (node.comfyClass === 'FreeVideoGenerate' || node.type === 'FreeVideoGenerate') rememberPromptDraft(api, node); + } + }); + } + return result; + }; +} function syncPrompts(reset = false) { if (configuringGraph && reset !== true) return; for (const node of app.graph?._nodes || []) if (node.type === 'FreeVideoGenerate') syncReferencePrompt(node, text, reset === true); @@ -593,7 +629,7 @@ const progressConnection = createProgressConnection(api, message => { app.registerExtension({ name: "FreeVideo.UnifiedMedia", - beforeConfigureGraph() { configuringGraph = true; }, + beforeConfigureGraph() { configuringGraph = true; configuration++; }, async setup() { // One failing part must not take the others down; health.js shows what failed. for (const [where, start] of [['progress', () => progressConnection.start()], ['navigation', () => installNavigation(showStudio, agentInterfaceEnabled(api))], @@ -601,6 +637,7 @@ app.registerExtension({ try { start(); } catch (error) { health({type: 'error', where, error}); } } window.addEventListener('pagehide', () => savePromptDraft(api, true)); + void savePromptDraft(api); // Retry this tab's outbox after an interrupted save. window.FreeVideoUI = {...window.FreeVideoUI, ready: true}; await notifyCompatibility(); }, @@ -654,13 +691,17 @@ app.registerExtension({ '修改采样步数可能降低生成质量。默认一采 8 步、二采 3 步;其他二采步数需小于一采步数。'); } } - const prompt = this.widgets?.find(w => w.name === 'text'); - if (prompt) { - // Every edit, in the creation panel or on the node, is the draft the launcher reopens. - const node = this, callback = prompt.callback; - prompt.callback = function (...args) { if (!configuringGraph) rememberPromptDraft(api, node); return callback?.apply(this, args); }; - prompt.inputEl?.addEventListener?.('input', () => rememberPromptDraft(api, node)); + for (const widget of this.widgets || []) { + // Node and creation-panel edits use the same named draft. + const node = this, callback = widget.callback; + widget.callback = function (...args) { + const result = callback?.apply(this, args); + if (!configuringGraph) rememberPromptDraft(api, node); + return result; + }; + widget.inputEl?.addEventListener?.('input', () => { if (!configuringGraph) rememberPromptDraft(api, node); }); } + trackCreationDraft(this); resultPanel(this); this.addWidget('button', text('Open creative workspace', '打开创作面板'), null, () => showStudio(this), {serialize: false}); const removed = this.onRemoved; @@ -673,6 +714,13 @@ app.registerExtension({ afterConfigureGraph() { syncPrompts(true); configuringGraph = false; + for (const node of app.graph?._nodes || []) { + if (node.comfyClass !== 'FreeVideoGenerate' && node.type !== 'FreeVideoGenerate') continue; + trackCreationDraft(node); + } + watchCreationGraph(app.graph); + // The host activates the target workflow after afterConfigureGraph. + requestAnimationFrame(() => watchPromptHistory(api, app)); progressConnection.reset(); progressConnection.refresh(); refreshNavigation(); diff --git a/web/launcher.js b/web/launcher.js index 836d8609..5c32c475 100644 --- a/web/launcher.js +++ b/web/launcher.js @@ -10,6 +10,41 @@ const studioWorkflow = graph => (graph?._nodes || graph?.nodes || []).some(n => const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); let configured = performance.now(); +async function existingStudioWorkflow(target, bundled) { + // Restored background tabs are lazy: load() reads the host's saved draft, + // whereas the workflow store's openWorkflow() only selects the tab record. + await target.load?.(); + const saved = target.changeTracker?.activeState; + // Leave the current graph intact; the existing startup check reports an + // unopened workflow without replacing the user's project. + if (!saved) return null; + const graph = structuredClone(saved); + if (!graph.nodes?.some(node => node.type === 'FreeVideoGenerate')) { + const node = structuredClone(bundled.nodes.find(node => node.type === 'FreeVideoGenerate')); + node.id = Math.max(graph.last_node_id || 0, ...graph.nodes.map(node => Number(node.id) || 0)) + 1; + graph.last_node_id = node.id; + node.pos = [Math.max(0, ...graph.nodes.map(node => (node.pos?.[0] || 0) + (node.size?.[0] || 400))) + 80, 100]; + for (const input of node.inputs || []) input.link = null; + for (const output of node.outputs || []) output.links = null; + graph.nodes.push(node); + // Reconnect a single unambiguous media source, leaving every existing + // node and link intact. Multiple media sources keep their own topology. + const media = graph.nodes.filter(node => node.type === 'FreeVideoMedia'); + const input = node.inputs?.findIndex(input => input.name === 'media') ?? -1; + const output = media.length === 1 ? media[0].outputs?.findIndex(output => output.type === node.inputs?.[input]?.type) ?? -1 : -1; + if (input >= 0 && output >= 0 && graph.version === .4) { + graph.links ||= []; + const id = Math.max(graph.last_link_id || 0, ...graph.links.map(link => link[0])) + 1; + graph.links.push([id, media[0].id, output, node.id, input, node.inputs[input].type]); + (media[0].outputs[output].links ||= []).push(id); + node.inputs[input].link = id; + graph.last_link_id = id; + } + } + graph.extra = {...graph.extra, freevideo_studio: true}; + return restorePromptDraft(api, graph, target.path); +} + // ComfyUI restores the previous session's workflow and tabs after every // extension's setup, so that restore can bring another workflow to the front // over ours. Once startup is quiet, bring our tab back into view even if a @@ -17,17 +52,31 @@ let configured = performance.now(); // setup returns. async function keepInFront(workflow, opened) { let touched = false; - const touch = event => { if (!event.target?.closest?.('.fv-studio, .fv-health')) touched = true; }; + const touch = event => { if (!event.target?.closest?.('.fv-health')) touched = true; }; addEventListener('pointerdown', touch, true); addEventListener('keydown', touch, true); try { const begin = performance.now(); while (performance.now() - begin < 15000 && (app.extensionManager?.spinner || performance.now() - configured < 600)) await sleep(100); - // An already visible FreeVideo graph may contain edits; keep that copy. - if (studioWorkflow(app.graph)) return; const store = app.extensionManager?.workflow; + // Opening the bundled graph before session restore overwrites the cached + // FreeVideo tab, including its media and connections. Reuse that project. + if (studioWorkflow(app.graph)) { + const current = app.graph.serialize(), target = store?.activeWorkflow; + const restored = await restorePromptDraft(api, structuredClone(current), target?.path || '__existing__'); + if (JSON.stringify(restored) !== JSON.stringify(current)) + await app.loadGraphData(restored, true, true, target || 'FreeVideo'); + return; + } // The user kept working in our own tab: its edits stay, even without the generate node. if (touched && opened && store?.activeWorkflow === opened) return; + const existing = store?.getWorkflowByPath?.('workflows/FreeVideo.json') + || store?.openWorkflows?.find(row => studioWorkflow(row.changeTracker?.activeState)); + if (existing) { + const restored = await existingStudioWorkflow(existing, workflow); + if (restored) await app.loadGraphData(restored, true, true, existing); + return; + } // Our own tab when ComfyUI still has it; a new one otherwise. const target = opened && store?.getWorkflowByPath?.(opened.path) === opened ? opened : 'FreeVideo'; await app.loadGraphData(structuredClone(workflow), true, true, target); diff --git a/web/prompt_draft.js b/web/prompt_draft.js index 28ccdb72..7fa30fa5 100644 --- a/web/prompt_draft.js +++ b/web/prompt_draft.js @@ -1,48 +1,205 @@ -// The launcher opens a fresh copy of the bundled workflow each time, so the -// prompt being written, finished or not, is kept in ComfyUI's user data and put -// back into that copy. The untouched starter prompt is the default, not a draft; -// a box emptied after typing is one, and reopens empty. +// The launcher starts from the bundled graph. Keep creation choices by name, +// alongside the existing schema-1 prompt draft; ordinary reloads keep their graph. const FILE = '/userdata/' + encodeURIComponent('freevideo/prompt-draft.json'); -let timer = null, pending = null; +const WIDGETS = ['text', 'width', 'height', 'seconds', 'seed', 'control_after_generate', 'two_pass', + 'base_steps', 'refine_steps', 'force_regenerate', 'preview', 'refine_from', 'continue_from', 'frames', 'first_frame_reference']; +const DEFAULTS = ['', 1344, 768, 10, 2026090903, 'randomize', true, 8, 3, false, false, '', '', 0, false]; +const SAVED_WIDGETS = WIDGETS.filter(name => !['text', 'force_regenerate', 'refine_from', 'continue_from', 'frames'].includes(name)); +const PROPERTIES = ['freevideo_aspect', 'freevideo_pixels', 'freevideo_seconds', 'freevideo_single', + 'freevideo_block_seconds', 'freevideo_run_mode', 'freevideo_batch_count']; +const OUTBOX = 'freevideo.prompt-pending'; +const clone = value => JSON.parse(JSON.stringify(value)); +const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const watched = new WeakMap(), dirty = new Set(); +const historyObservers = new WeakSet(), restoringHistory = new WeakSet(); +let timer = null, pending = null, inFlight = null, sending = null; +try { pending = JSON.parse(globalThis.sessionStorage?.getItem(OUTBOX) || 'null'); } catch {} + +function retain() { + try { + if (pending || sending) globalThis.sessionStorage?.setItem(OUTBOX, JSON.stringify(mergePatches(sending, pending))); + else globalThis.sessionStorage?.removeItem(OUTBOX); + } catch { /* Saving still works when browser storage is disabled. */ } +} export function promptDraft(node) { const text = node?.widgets?.find(w => w.name === 'text')?.value; if (typeof text !== 'string') return null; const versions = node.properties?.freevideo_prompt_versions; - return {schema: 1, text: node.freevideoStarterPrompt && text === node.freevideoStarterPrompt ? null : text, - versions: versions && typeof versions === 'object' ? versions : null}; + const draft = {schema: 1, text: node.freevideoStarterPrompt && text === node.freevideoStarterPrompt ? null : text, + versions: versions && typeof versions === 'object' ? clone(versions) : null}; + const widgets = Object.fromEntries((node.widgets || []).filter(w => SAVED_WIDGETS.includes(w.name)).map(w => [w.name, w.value])); + const properties = Object.fromEntries(PROPERTIES.filter(key => Object.hasOwn(node.properties || {}, key)).map(key => [key, node.properties[key]])); + if (Object.keys(widgets).length) draft.widgets = clone(widgets); + if (Object.keys(properties).length) draft.properties = clone(properties); + return draft; } export function applyPromptDraft(workflow, draft) { const node = workflow?.nodes?.find(row => row.type === 'FreeVideoGenerate'); if (!node || !Array.isArray(node.widgets_values) || draft?.schema !== 1) return workflow; if (typeof draft.text === 'string') node.widgets_values[0] = draft.text; + if (draft.project && node.properties) { + for (const key of PROPERTIES) if (!Object.hasOwn(draft.properties || {}, key)) delete node.properties[key]; + if (!draft.versions) delete node.properties.freevideo_prompt_versions; + } + for (const name of SAVED_WIDGETS) { + const value = draft.widgets?.[name], index = WIDGETS.indexOf(name); + if (value === undefined || typeof value !== typeof DEFAULTS[index] + || (typeof value === 'number' && !Number.isFinite(value))) continue; + while (node.widgets_values.length <= index) node.widgets_values.push(DEFAULTS[node.widgets_values.length]); + node.widgets_values[index] = value; + } + for (const key of PROPERTIES) if (Object.hasOwn(draft.properties || {}, key)) { + node.properties ||= {}; + node.properties[key] = draft.properties[key]; + } const versions = draft.versions; if (versions && typeof versions.original === 'string' && typeof versions.rewritten === 'string') - node.properties = {...node.properties, freevideo_prompt_versions: versions}; + node.properties = {...node.properties, freevideo_prompt_versions: clone(versions)}; return workflow; } +// Baseline comes from the configured node, never the shared server draft. A stale +// tab therefore contributes only its actual edits, including property resets. +export function trackPromptDraft(node, onChange, project) { + if (dirty.has(node)) collect(node); + watched.set(node, {last: promptDraft(node), onChange: onChange || watched.get(node)?.onChange || (() => {}), + project: project || watched.get(node)?.project}); +} + +function combine(base, patch) { + const value = {...(patch.project && (base?.schema !== 1 || (base.project && patch.project !== base.project)) ? patch.snapshot + : base?.schema === 1 ? base : {schema: 1, text: null, versions: null})}; + for (const key of ['text', 'versions', 'project']) if (Object.hasOwn(patch, key)) value[key] = patch[key]; + for (const group of ['widgets', 'properties']) if (patch[group]) { + value[group] = {...value[group]}; + for (const [key, entry] of Object.entries(patch[group])) { + if (entry === null) delete value[group][key]; else value[group][key] = entry; + } + } + return value; +} +function mergePatches(base, patch) { + if (patch?.project && base?.project && patch.project !== base.project) base = null; + return {...base, ...patch, + widgets: {...base?.widgets, ...patch?.widgets}, properties: {...base?.properties, ...patch?.properties}}; +} +function addPatch(patch) { + pending = mergePatches(pending, patch); + retain(); +} +function collect(node, notify = true) { + dirty.delete(node); + const next = promptDraft(node), state = watched.get(node); + if (!next) return; + const previous = state?.last || {text: null, versions: null}; + const patch = {}; + if (!equal(next.text, previous.text) || !equal(next.versions, previous.versions)) { + patch.text = next.text; patch.versions = next.versions; + } + for (const group of ['widgets', 'properties']) { + for (const key of new Set([...Object.keys(previous[group] || {}), ...Object.keys(next[group] || {})])) { + if (!equal(previous[group]?.[key], next[group]?.[key])) { + (patch[group] ||= {})[key] = next[group]?.[key] ?? null; + } + } + } + if (Object.keys(patch).length) { + const project = state?.project?.(); + if (project) { patch.project = project; patch.snapshot = next; } + addPatch(patch); if (notify) state?.onChange(); + } + watched.set(node, {last: next, onChange: state?.onChange || (() => {}), project: state?.project}); +} + export function rememberPromptDraft(api, node) { - pending = node; clearTimeout(timer); + dirty.add(node); + // Compound edits finish their widget callbacks and properties in this turn. + queueMicrotask(() => { if (dirty.has(node)) collect(node); }); + clearTimeout(timer); timer = setTimeout(() => savePromptDraft(api), 500); } -// keepalive lets the last edit outlive a closing page. +export function promptHistoryRestoring(app) { + return restoringHistory.has(app.extensionManager?.workflow?.activeWorkflow?.changeTracker); +} + +// The host replaces node instances when replaying history. Observe the completed +// undo/redo transaction, comparing against the nodes it replaced, without adding +// a new undo step or treating an ordinary project load as an edit. +export function watchPromptHistory(api, app) { + const workflow = app.extensionManager?.workflow?.activeWorkflow, tracker = workflow?.changeTracker; + if (!tracker || historyObservers.has(tracker)) return; + historyObservers.add(tracker); + for (const method of ['undo', 'redo']) { + const replay = tracker[method]; + if (typeof replay !== 'function') continue; + tracker[method] = async function (...args) { + if (app.extensionManager?.workflow?.activeWorkflow !== workflow) return replay.apply(this, args); + for (const node of dirty) collect(node); + const previous = new Map((app.graph?._nodes || []).map(node => [node.id, promptDraft(node)])); + restoringHistory.add(tracker); + let result; + try { result = await replay.apply(this, args); } + finally { restoringHistory.delete(tracker); } + if (app.extensionManager?.workflow?.activeWorkflow !== workflow) return result; + for (const node of app.graph?._nodes || []) { + if (node.type !== 'FreeVideoGenerate' && node.comfyClass !== 'FreeVideoGenerate') continue; + const state = watched.get(node); + watched.set(node, {...state, project: () => workflow.path, + last: previous.get(node.id) || {text: null, versions: null}}); + rememberPromptDraft(api, node); + collect(node, false); + } + return result; + }; + } +} + +// Serialize read/merge/write across tabs and keep failed patches for retry. The +// per-tab outbox also survives refreshing during an in-flight request. export async function savePromptDraft(api, keepalive = false) { clearTimeout(timer); timer = null; - const draft = promptDraft(pending); pending = null; - if (!draft) return false; - try { - const reply = await api.fetchApi(FILE + '?overwrite=true', {method: 'POST', body: JSON.stringify(draft), keepalive}); - return reply.ok; - } catch { return false; } + for (const node of dirty) collect(node); + if (inFlight) { + if (!await inFlight) return false; + return pending ? savePromptDraft(api, keepalive) : true; + } + if (!pending) return false; + const patch = pending; pending = null; sending = patch; + const write = async () => { + try { + const reply = await api.fetchApi(FILE, {cache: 'no-store', keepalive}); + if (!reply.ok && reply.status !== 404) throw new Error('draft_read'); + const current = reply.ok ? await reply.json() : null; + const saved = await api.fetchApi(FILE + '?overwrite=true', {method: 'POST', + headers: {'Content-Type': 'application/json'}, body: JSON.stringify(combine(current, patch)), keepalive}); + if (!saved.ok) throw new Error('draft_write'); + sending = null; retain(); return true; + } catch { + const newer = pending; pending = patch; sending = null; + if (newer) addPatch(newer); else retain(); + return false; + } + }; + inFlight = globalThis.navigator?.locks ? navigator.locks.request('freevideo-prompt-draft', write) : write(); + try { return await inFlight; } finally { inFlight = null; } } -export async function restorePromptDraft(api, workflow) { +export async function restorePromptDraft(api, workflow, project = null) { + // A refreshed page retries its own unsaved patch before launcher restoration. + if (pending || inFlight || dirty.size) await savePromptDraft(api); + let draft; try { const reply = await api.fetchApi(FILE, {cache: 'no-store'}); - if (reply.ok) return applyPromptDraft(workflow, await reply.json()); - } catch { /* No draft yet, or unreadable: open the bundled prompt. */ } + if (reply.ok) draft = await reply.json(); + } catch { /* Keep the workflow and any local edits if the server is offline. */ } + // A failed retry remains authoritative over the older remote copy. Retain + // the outbox until a later explicit save succeeds, including across reloads. + const local = pending || sending ? mergePatches(sending, pending) : null; + if (local && (!project || !local.project || local.project === project)) draft = combine(draft, local); + // Unscoped schema-1 drafts predate project ownership and must migrate too. + if (!project || !draft?.project || draft.project === project) return applyPromptDraft(workflow, draft); return workflow; } diff --git a/web/studio.js b/web/studio.js index 23b978a2..3e021cea 100644 --- a/web/studio.js +++ b/web/studio.js @@ -22,7 +22,7 @@ import { reconcileLoraTriggers } from './lora_triggers.js'; import { createSamplingEffort } from './sampling_effort.js'; import { resultActions, previewBadge, icon } from './result_actions.js'; import { createPromptEnhancer } from './prompt_enhance.js'; -import { rememberPromptDraft } from './prompt_draft.js'; +import { rememberPromptDraft, trackPromptDraft } from './prompt_draft.js'; import { canvasDimensions, imageSizeSelection } from './studio_size.js'; import { createCapabilityView } from './capability_view.js'; import { openEditorView } from './editor_loader.js'; @@ -48,7 +48,8 @@ const linked = (node, name) => node?.inputs?.some(input => input.name === name & const value = (node, name) => widget(node, name)?.value; function set(node, name, value) { const w = widget(node, name); if (!w || linked(node, name)) return false; - w.value = value; w.callback?.(value); node.graph?.change(); node.setDirtyCanvas?.(true, true); return true; + w.value = value; w.callback?.(value); node.graph?.change(); node.setDirtyCanvas?.(true, true); + rememberPromptDraft(api, node); return true; } function upstream(node, name) { const input = node.inputs?.find(i => i.name === name); @@ -344,6 +345,9 @@ export function openStudio(node, {extendFrom = null} = {}) { const extension = createExtendState(node, name => value(node, name), (name, v) => set(node, name, v)); if (extendFrom && !extension.enter(extendFrom)) throw new Error(extendText(t, 'unavailable')); const dialog = el('dialog', null, 'fv-studio'); dialog.setAttribute('aria-label', t('FreeVideo creative workspace', 'FreeVideo 创作面板')); + // ComfyUI's background dialogs can lock body pointer events. This native + // modal is above them and must remain interactive when the launcher reopens it. + dialog.style.pointerEvents = 'auto'; current = {node, dialog}; const cleanup = []; const header = el('header', null, 'fv-header'), brand = el('div', null, 'fv-brand'); brand.append(wordmark()); @@ -863,7 +867,8 @@ export function openStudio(node, {extendFrom = null} = {}) { } const runbar = el('div', null, 'fv-runbar'); let busy = false, queued = false, cancelling = false, activePrompt = null, capturing = false; - let runMode = 'single', queueState; + let runMode = ['single', 'batch', 'loop'].includes(node.properties?.freevideo_run_mode) + ? node.properties.freevideo_run_mode : 'single', queueState; if (!studioQueues.has(node)) studioQueues.set(node, createStudioQueue(api, node.id, { formatError: (error, snapshot) => createErrorReport(error, {stage: 'submission', snapshot, node, graph: app.graph}), // Direct snapshot submission still feeds the same graph result panel. @@ -923,14 +928,23 @@ export function openStudio(node, {extendFrom = null} = {}) { optionsPanel.append(el('strong', t('Generation mode', '生成方式'))); const modeButtons = []; for (const [id, label] of [['single', t('One video', '单次生成')], ['batch', t('Batch', '批量生成')], ['loop', t('Loop', '循环生成')]]) { - const b = button(label, () => { runMode = id; updateRunButton(); }); + const b = button(label, () => { + runMode = id; node.properties.freevideo_run_mode = id; + rememberPromptDraft(api, node); updateRunButton(); + }); b.dataset.mode = id; modeButtons.push(b); optionsPanel.append(b); } - const count = el('input'); count.type = 'number'; count.min = '2'; count.max = '100'; count.step = '1'; count.value = '4'; + const count = el('input'); count.type = 'number'; count.min = '2'; count.max = '100'; count.step = '1'; + count.value = node.properties?.freevideo_batch_count ?? 4; count.setAttribute('aria-label', t('Number of videos', '生成数量')); const countField = field(t('Number of videos', '生成数量'), count); optionsPanel.append(countField); const modeHelp = el('p', '', 'fv-muted'); optionsPanel.append(modeHelp); - count.oninput = () => updateRunButton(); + count.oninput = () => { + if (count.value && count.checkValidity()) { + node.properties.freevideo_batch_count = Number(count.value); rememberPromptDraft(api, node); + } + updateRunButton(); + }; runOptions.append(optionsToggle, optionsPanel); const generate = button('', submitDraft, 'fv-primary'); generate.dataset.fvGuide = 'generate'; @@ -985,7 +999,10 @@ export function openStudio(node, {extendFrom = null} = {}) { const submitting = capturing || queueState?.submitting; updateFirstFrame(); const multiple = lengthControl.plan?.count > 1; - if (multiple) runMode = 'single'; + // A long/continued video temporarily uses one run. Keep the user's mode + // preference so returning to a short video and reloading show the same choice. + runMode = multiple || extension.source ? 'single' + : ['single', 'batch', 'loop'].includes(node.properties?.freevideo_run_mode) ? node.properties.freevideo_run_mode : runMode; generate.disabled = submitting || chainController?.active || lengthControl.blocked || (runMode === 'loop' && queueState?.looping); generate.textContent = submitting ? t('Adding…', '正在添加…') : runMode === 'batch' ? t(`Add ${count.value || '…'} videos`, `添加 ${count.value || '…'} 条任务`) @@ -1284,5 +1301,7 @@ export function openStudio(node, {extendFrom = null} = {}) { } }).catch(() => {}); requestAnimationFrame(() => { if (dialog.open && !dialog.querySelector('.fv-guide-layer') && !document.querySelector('dialog.fv-editor[open]')) (extension.source ? prompt : generate).focus({preventScroll: true}); }); + // Establish the baseline after UI initialization; opening a panel is not an edit. + trackPromptDraft(node); return dialog; } From 18e105200d118873ab79b0b2171c64fe98e04709 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:09:18 +0000 Subject: [PATCH 20/53] Duration controls explain lengths beyond one run and keep the forced single run across refreshes --- web/capability_view.js | 49 +++++++++++++++++++++++++++++++++++------- web/studio_length.js | 25 ++++++++++++++------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/web/capability_view.js b/web/capability_view.js index b2f84a29..9df6df14 100644 --- a/web/capability_view.js +++ b/web/capability_view.js @@ -118,7 +118,22 @@ export function capabilitySurface(grid, state) { const point = points.reduce((best, p) => Math.abs(p.seconds - seconds) < Math.abs(best.seconds - seconds) ? p : best); return {...point, width: sizes[i].width, height: sizes[i].height, pixels: sizes[i].pixels, size: i}; } - return {sizes, xs, top, edge, snap, at: seconds => curve(available.map(i => xs[i]), available.map(i => curves[i](seconds)))}; + function nearest(x, y) { + if (![x, y].every(Number.isFinite) || x < 0 || x > 1 || y < 0 || y > 1) return null; + let best = null, distance = Infinity; + sizes.forEach((size, i) => { + for (const point of size.points) { + if (point.seconds > top) continue; + const d = (xs[i] - x) ** 2 + (1 - point.seconds / top - y) ** 2; + if (d < distance) { + distance = d; + best = {...point, width: size.width, height: size.height, pixels: size.pixels, size: i}; + } + } + }); + return best; + } + return {sizes, xs, top, edge, snap, nearest, at: seconds => curve(available.map(i => xs[i]), available.map(i => curves[i](seconds)))}; } // Iris's blue ramp in OKLab, interpolated in linear light; transparent beyond @@ -172,7 +187,8 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate for (const item of [canvas, previous, now, mark]) item.setAttribute('aria-hidden', 'true'); tip.setAttribute('role', 'status'); tip.append(time, configuration); plot.append(canvas, previous, now, mark, tip); plot.tabIndex = 0; plot.setAttribute('role', 'group'); plot.setAttribute('aria-label', t('Available sizes and lengths', '可生成范围')); - chart.append(yAxis, plot, xAxis); element.append(entry, chart); + const notice = el('p', 'fv-muted'); notice.hidden = true; notice.setAttribute('role', 'status'); + chart.append(yAxis, plot, xAxis); element.append(entry, chart, notice); let opened = false; try { opened = localStorage.getItem('freevideo.range-open') === '1'; } catch {} const fold = () => { chart.hidden = !opened; entry.setAttribute('aria-expanded', String(opened)); }; @@ -293,10 +309,25 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate function point(event) { if (!surface) return; const box = plot.getBoundingClientRect(), x = clamp(event.clientX - box.left, 0, box.width), y = clamp(event.clientY - box.top, 0, box.height); + if (!box.width || !box.height) return; const snapped = surface.snap(x / box.width, y / box.height); if (snapped) show(snapped, x, y); else hideHover(); + return {x: x / box.width, y: y / box.height}; } - const pick = () => { if (hover && !getState().disabled) { const point = hover; onPick(point); current(); } }; + const pick = position => { + if (!surface || getState().disabled) return; + let selected = hover, adjusted = Boolean(selected?.adjusted); + if (!selected) { + const x = position?.x ?? surface.xs[Math.max(0, currentSize())]; + const y = position?.y ?? clamp(1 - Number(value(node, 'seconds')) / surface.top, 0, 1); + selected = surface.snap(x, y); + if (!selected) { selected = surface.nearest(x, y); adjusted = true; } + } + if (!selected) return; + onPick(selected); current(); + notice.hidden = !adjusted; + notice.textContent = adjusted ? t('Moved to the nearest available spot.', '已挪到最近的可用位置。') : ''; + }; plot.onpointermove = point; plot.onpointerdown = event => { if (event.button !== 0) return; @@ -304,14 +335,14 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate }; plot.onpointerup = event => { if (event.pointerId !== pointer) return; - point(event); pointer = null; plot.releasePointerCapture(event.pointerId); pick(); + const position = point(event); pointer = null; plot.releasePointerCapture(event.pointerId); pick(position); }; plot.onpointercancel = () => { pointer = null; hideHover(); }; plot.onlostpointercapture = () => { pointer = null; }; plot.onpointerleave = event => { if (pointer === null && event.pointerType !== 'touch') hideHover(); }; plot.onblur = hideHover; plot.onkeydown = event => { - if (!surface) return; + if (!surface || event.ctrlKey || event.metaKey || event.altKey) return; if (['Enter', ' '].includes(event.key)) { event.preventDefault(); pick(); return; } const delta = {ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1]}[event.key]; if (!delta) return; @@ -319,10 +350,12 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate const base = hover || {size: Math.max(0, currentSize()), seconds: Math.round(Number(value(node, 'seconds')))}; let i = clamp(base.size + delta[0], 0, surface.sizes.length - 1); while (!surface.sizes[i].points.length && delta[0] && i + delta[0] >= 0 && i + delta[0] < surface.sizes.length) i += delta[0]; - if (!surface.sizes[i].points.length) return; - const seconds = clamp(base.seconds + delta[1], 2, Math.min(surface.top, surface.sizes[i].max)); + const available = surface.sizes[i].points.length > 0; + const seconds = clamp(base.seconds + delta[1], 2, available ? Math.min(surface.top, surface.sizes[i].max) : surface.top); const x = surface.xs[i], y = 1 - seconds / surface.top, snapped = surface.snap(x, y); - if (snapped) show(snapped, x * plot.clientWidth, y * plot.clientHeight); + const selected = snapped || surface.nearest(x, y); + if (selected) show({...selected, adjusted: !snapped}, surface.xs[selected.size] * plot.clientWidth, + (1 - selected.seconds / surface.top) * plot.clientHeight); }; function changed(event) { if (event.detail?.revision === grid?.revision) return; diff --git a/web/studio_length.js b/web/studio_length.js index 201a722f..747cd000 100644 --- a/web/studio_length.js +++ b/web/studio_length.js @@ -42,7 +42,6 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie note.hidden = true; fields.after(note); note.setAttribute('role', 'status'); let limits = null, planLimits = null, contextKey = '', revision = 0, timer = null, request = null, capabilityRequest = null, capabilityRevision = 0; let capabilityState = 'idle', waiting = false, waitingTimer = null, timeoutTimer = null, submitted = false; - let segmentationEdited = node.properties?.[LENGTH_PROPERTIES.single] === true || Object.hasOwn(node.properties || {}, LENGTH_PROPERTIES.block); let plan = null, error = null, absent = false, disposed = false, pending = null, edited = false, blockEdited = false; const remember = (key, value) => { node.properties ||= {}; node.properties[key] = value; node.graph?.change(); node.setDirtyCanvas?.(true, true); }; const total = () => Number(seconds.value); @@ -60,12 +59,20 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie const cannot = total() > (forcedMaximum ?? 15); single.disabled = absent || seconds.disabled || cannot; single.checked = !absent && effectiveSingle(); if (absent) seconds.max = '60'; - unavailable.hidden = absent || !planLimits || (!cannot && !clamped); - unavailable.textContent = planLimits ? t(`Up to ${Math.floor(clamped ? maximum : forcedMaximum)} s in one run at this size.`, `当前画面大小单次最长 ${Math.floor(clamped ? maximum : forcedMaximum)} 秒。`) : ''; + const reason = cannot ? positive(forcedMaximum) + ? t(`Up to ${Math.floor(forcedMaximum)}\u00a0s in one run at this size.`, `当前画面大小单次最长 ${Math.floor(forcedMaximum)}\u00a0秒。`) + : t('The maximum length is unknown for these settings.', '当前设置下无法确定最长时长。') + : seconds.disabled ? '' : clamped + ? t(`Up to ${Math.floor(maximum)}\u00a0s in one run at this size.`, `当前画面大小单次最长 ${Math.floor(maximum)}\u00a0秒。`) : ''; + unavailable.hidden = absent || !reason; unavailable.textContent = reason; + single.title = single.disabled ? reason : ''; toggle.title = single.title; if (positive(maximum)) block.max = String(maximum); else block.removeAttribute('max'); if (!edited) block.value = Math.min(node.properties?.[LENGTH_PROPERTIES.block] || defaultBlockSeconds(maximum), maximum || Infinity); block.disabled = absent || seconds.disabled || effectiveSingle() || (positive(maximum) && maximum < 5); controls.hidden = absent; reset.hidden = !Object.hasOwn(node.properties || {}, LENGTH_PROPERTIES.block); reset.disabled = block.disabled; + block.title = !block.disabled || absent || seconds.disabled || effectiveSingle() ? '' + : t('These settings cannot generate in parts.', '当前设置不能分段生成。'); + reset.title = block.title; note.hidden = absent || !error; note.textContent = error ? errorMessage(error) : ''; } @@ -98,15 +105,15 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie return plan; } function schedule() { const version = revision; timer = setTimeout(() => { timer = null; pending = calculate(version); }, delay); } - single.onchange = () => { remember(LENGTH_PROPERTIES.single, single.checked); segmentationEdited = true; edited = true; invalidate(); schedule(); }; - block.oninput = () => { segmentationEdited = true; edited = blockEdited = true; invalidate(); schedule(); }; + single.onchange = () => { remember(LENGTH_PROPERTIES.single, single.checked); edited = true; invalidate(); schedule(); }; + block.oninput = () => { edited = blockEdited = true; invalidate(); schedule(); }; block.onchange = () => { if (block.reportValidity()) remember(LENGTH_PROPERTIES.block, Number(block.value)); }; reset.onclick = () => { delete node.properties[LENGTH_PROPERTIES.block]; node.graph?.change(); submitted = false; - segmentationEdited = true; planLimits = limits; + planLimits = limits; edited = blockEdited = false; invalidate(); edited = true; schedule(); }; - const input = () => { submitted = false; if (!segmentationEdited) planLimits = limits; edited = false; invalidate(); schedule(); }; + const input = () => { submitted = false; planLimits = limits; edited = false; invalidate(); schedule(); }; seconds.addEventListener('input', input); function commit() { if (!seconds.value || !seconds.reportValidity()) return false; @@ -147,7 +154,9 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie if (disposed || controller.signal.aborted || version !== capabilityRevision) return; clearTimeout(waitingTimer); clearTimeout(timeoutTimer); waiting = false; limits = value; capabilityState = value ? 'ready' : 'unavailable'; - if (value && !segmentationEdited && !submitted) { planLimits = value; invalidate(); schedule(); } + // Draft edits choose segmentation, not a permanent device limit. + // A submitted job keeps the exact bounds used for its snapshot. + if (value && !submitted) { planLimits = value; invalidate(); schedule(); } else { paint(); onChange(); } }; timeoutTimer = setTimeout(() => { settle(null); controller.abort(); }, capabilityTimeout); From 888e54e48d8ddf4a43df9df57e10ad7be1bf5181 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:09:32 +0000 Subject: [PATCH 21/53] Part-by-part videos report cancel, resume and finished states after refresh and say why they fail --- web/studio.js | 34 +++++++++++++++++++++++++---- web/studio_chain.js | 53 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/web/studio.js b/web/studio.js index 3e021cea..83c9ec79 100644 --- a/web/studio.js +++ b/web/studio.js @@ -881,6 +881,7 @@ export function openStudio(node, {extendFrom = null} = {}) { }, })); const queueController = studioQueues.get(node); + let chainWasActive = false; chainController = createStudioChain({api, node, onProgress: (message, chain) => { const position = chainPosition(chain, message); const joining = readChainResult(chain).joining, waiting = chain.state === 'queued' && message.phase === 'prepare'; @@ -892,6 +893,11 @@ export function openStudio(node, {extendFrom = null} = {}) { }, onState: chain => { const active = chainController.active, position = chainPosition(chain), outcome = readChainResult(chain); + if (active && !chainWasActive) { + failure.clear(); node.freevideoFailure = ''; node.freevideoFailureReport = null; + lastQueueError = null; status.textContent = ''; status.dataset.error = 'false'; + } + chainWasActive = active; busy = active || Boolean(queueState?.running || queueState?.pending.length); queued = chain?.state === 'queued'; chainLine.hidden = !active; chainLine.textContent = outcome?.joining ? t('Joining the parts.', '正在接合各段。') @@ -976,6 +982,8 @@ export function openStudio(node, {extendFrom = null} = {}) { loop_stopped: t('The last request did not complete. The loop has stopped.', '上一条任务未完成,循环已停止。'), linked_seed: t('Use the seed in this panel for batch or loop generation.', '批量和循环生成需使用面板内的种子,请先断开种子输入的连接。'), missing_node: t('This node is disabled or no longer in the workflow.', '该节点已禁用或已从工作流移除。'), + chain_cancel_failed: t('Could not cancel. Refresh the page and try again.', '没有取消成功,请刷新页面后再试。'), + chain_resume_failed: t('Could not continue. Refresh the page and try again.', '没有继续生成,请刷新页面后再试。'), }[error?.message] || (error?.said ? t(error.said[0], error.said[1]) : null) || error?.message || String(error)); let lastQueueError = null; function showQueueError(error) { @@ -989,6 +997,8 @@ export function openStudio(node, {extendFrom = null} = {}) { ? t('Submission failed. See the details below.', '提交失败,具体原因见下方。') : extensionCause(error) ? t('A browser extension is interfering with FreeVideo. See details below.', '浏览器扩展干扰了 FreeVideo,详见下方。') : queueError(error); + // Recoverable chain actions need one visible sentence, not a second error card. + if (error?.action) { status.scrollIntoView({block: 'nearest'}); return; } const identity = error?.schema === 'freevideo.error-report' ? error : error?.message || error; if (lastQueueError === identity) return; lastQueueError = identity; @@ -1003,12 +1013,18 @@ export function openStudio(node, {extendFrom = null} = {}) { // preference so returning to a short video and reloading show the same choice. runMode = multiple || extension.source ? 'single' : ['single', 'batch', 'loop'].includes(node.properties?.freevideo_run_mode) ? node.properties.freevideo_run_mode : runMode; + const chain = chainController?.state, position = chainPosition(chain); + const chainReason = !chainController?.active ? '' : readChainResult(chain)?.joining + ? t('Joining parts…', '正在拼接…') + : chain?.state === 'queued' ? t('Queued…', '排队中…') + : t(`Generating part ${position.index + 1}…`, `正在生成第 ${position.index + 1} 段…`); generate.disabled = submitting || chainController?.active || lengthControl.blocked || (runMode === 'loop' && queueState?.looping); generate.textContent = submitting ? t('Adding…', '正在添加…') - : runMode === 'batch' ? t(`Add ${count.value || '…'} videos`, `添加 ${count.value || '…'} 条任务`) + : chainReason || (runMode === 'batch' ? t(`Add ${count.value || '…'} videos`, `添加 ${count.value || '…'} 条任务`) : runMode === 'loop' ? (queueState?.looping ? t('Looping', '循环中') : t('Start loop', '开始循环')) : busy ? t('Add to queue', '添加到队列') - : previewFirst.checked && !previewFirst.disabled ? t('Generate preview', '生成预览') : t('Generate video', '生成视频'); + : previewFirst.checked && !previewFirst.disabled ? t('Generate preview', '生成预览') : t('Generate video', '生成视频')); + generate.title = (lengthControl.blocked ? lengthControl.message : submitting ? t('Adding…', '正在添加…') : ''); for (const b of modeButtons) b.setAttribute('aria-pressed', String(b.dataset.mode === runMode)); for (const b of modeButtons) if (b.dataset.mode !== 'single') b.disabled = multiple; countField.hidden = runMode !== 'batch'; count.disabled = runMode !== 'batch'; @@ -1024,7 +1040,10 @@ export function openStudio(node, {extendFrom = null} = {}) { } function updateExtendButton() { const button = links.querySelector('.fv-action-extend'); - if (button) button.disabled = busy || queued || capturing || Boolean(chainController?.active); + if (button) { + button.disabled = capturing; + button.title = capturing ? t('Adding…', '正在添加…') : ''; + } } function renderQueue(state) { if (disposed) return; @@ -1227,7 +1246,14 @@ export function openStudio(node, {extendFrom = null} = {}) { busy = cancelling = false; activePrompt = null; updateRunButton(); hideProgress(); failure.clear(); node.freevideoFailure = ''; node.freevideoFailureReport = null; status.dataset.error = 'false'; status.textContent = t('Cancelled', '已取消'); }); - const escape = e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); generate.click(); } }; dialog.addEventListener('keydown', escape); + const escape = e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + if (generate.disabled) { + generate.scrollIntoView({block: 'center'}); + return; + } + generate.click(); + } }; dialog.addEventListener('keydown', escape); dialog.addEventListener('cancel', e => { e.preventDefault(); closeDialog(dialog); }); dialog.onclose = () => { disposed = true; for (const f of cleanup) f(); stage.querySelector('video')?.pause(); dialog.remove(); if (current?.dialog === dialog) { current = null; viewChanged('nodes', node); } }; capabilityView = createCapabilityView({node, api, t, getState: () => ({ratio, diff --git a/web/studio_chain.js b/web/studio_chain.js index c3e8d72c..7168c418 100644 --- a/web/studio_chain.js +++ b/web/studio_chain.js @@ -52,16 +52,16 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( function clearPolls() { for (const id of timers) clearTimeout(id); timers.clear(); } function refresh() { clearPolls(); return poller?.poll(); } const active = () => submitting || activeStates.has(chain?.state) || chain?.output_state === 'joining'; - async function json(path, options) { + async function json(path, options, allowMissing = false) { const response = await api.fetchApi(path, options); - if (response.status === 404) return null; - const value = await response.json(); + if (response.status === 404 && allowMissing) return null; + const value = await response.json().catch(() => ({})); if (!response.ok) throw Object.assign(new Error(value.error || 'chain_request_failed'), {code: value.code, status: response.status, ...(value.error_zh ? {said: [value.error, value.error_zh]} : {})}); return value; } function progress(message = {}, fresh = false) { - if (!chain || disposed) return; + if (!chain || disposed || (!activeStates.has(chain.state) && chain.output_state !== 'joining')) return; if (message.chain?.id && message.chain.id !== chain.id) return; const position = chainPosition(chain, message); if (position.index !== lastIndex) { lastMessage = {}; lastIndex = position.index; } @@ -122,17 +122,48 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( const version = epoch; try { const known = node.properties?.[CHAIN_PROPERTY]?.id; - const reply = await json('/freevideo/chains'); + const reply = await json('/freevideo/chains', undefined, true); if (disposed || version !== epoch || chain) return; const found = reply?.chains?.find(row => String(row.node) === String(node.id) && (known ? row.id === known : activeStates.has(row.state) && row.client_id && row.client_id === api.clientId)); if (!found) return; - const value = (await json(chainRoute(found.id)))?.chain; + const value = (await json(chainRoute(found.id), undefined, true))?.chain; if (disposed || version !== epoch || chain || !value) return; adopt(value); if (node.freevideoProgress?.chain?.id === value.id) progress(node.freevideoProgress); } catch {} // Recovery must never enqueue a request or block ordinary generation. } + function acceptAction(value, restarted) { + epoch++; poller?.dispose(); clearPolls(); + if (restarted) { + previous = {}; lastMessage = {}; lastIndex = -1; corrected = false; etaAt = 0; + delivered = errorShown = null; + } + deliver(value, restarted); if (active()) watch(); + } + async function changeState(action, body) { + const id = chain.id, version = epoch; + try { + const value = (await json(chainRoute(id, action), post(body)))?.chain; + if (!value?.id) throw new Error('chain_missing'); + if (version === epoch && !disposed) acceptAction(value, action === 'resume'); + } catch (cause) { + // The action may have reached the server even if its reply failed. + // Read directly: a stopped chain has no active poller to refresh. + if (version === epoch && !disposed) { + try { + const latest = (await json(chainRoute(id), {cache: 'no-store'}))?.chain; + if (latest?.id === id && version === epoch && !disposed) { + const confirmed = action === 'cancel' ? ['cancelling', 'cancelled'].includes(latest.state) + : ['queued', 'running'].includes(latest.state) || (latest.state === 'done' && latest.output_state !== 'failed'); + acceptAction(latest, action === 'resume' && confirmed); + if (confirmed) return; + } + } catch {} // Keep the last known state; the action still fails visibly. + } + throw Object.assign(new Error(`chain_${action}_failed`), {cause, action, status: cause.status}); + } + } recover(); return { get active() { return active(); }, get state() { return chain; }, @@ -142,7 +173,7 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( try { const body = buildChainRequest(options, {node: node.id, prompt: snapshot.output, extra_data: {extra_pnginfo: {workflow: snapshot.workflow || {}}}, ...(api.clientId ? {client_id: api.clientId} : {})}); - const value = (await json(chainRoute(), post(body)))?.chain; + const value = (await json(chainRoute(), post(body), true))?.chain; if (!value) return null; if (!value.id) throw new Error('chain_missing'); const inputs = snapshot.output[String(node.id)].inputs; @@ -153,15 +184,11 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( refresh, async cancel() { if (!chain) return; - const id = chain.id, version = epoch; - const value = (await json(chainRoute(id, 'cancel'), post({})))?.chain; - if (version === epoch && !disposed && value) { epoch++; poller?.dispose(); clearPolls(); deliver(value); if (active()) watch(); } + await changeState('cancel', {}); }, async resume() { if (!chain || active()) return; - const id = chain.id, version = epoch; - const value = (await json(chainRoute(id, 'resume'), post({client_id: api.clientId})))?.chain; - if (version === epoch && !disposed && value) { epoch++; poller?.dispose(); clearPolls(); previous = {}; corrected = false; etaAt = 0; delivered = errorShown = null; deliver(value, true); if (active()) watch(); } + await changeState('resume', {client_id: api.clientId}); }, progress(message) { if (!chain || message.chain?.id !== chain.id) return false; progress(message); if (message.result || ['complete', 'failed', 'cancelled'].includes(message.phase)) refresh(); return true; }, From 65845ce8f683cb4a30192e51b0076393da87f05d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:09:56 +0000 Subject: [PATCH 22/53] Result actions, sharing and second pass report failures and progress; update reminder keeps state --- scripts/test_freevideo_entry.mjs | 7 ++++-- web/freevideo.js | 22 ++++++----------- web/library.js | 20 +++++++++------- web/prompt_enhance.js | 13 +++++++--- web/result_actions.js | 29 +++++++++++++++------- web/share.js | 41 +++++++++++++++++++++++++------- web/studio.js | 19 +++++++++++---- web/studio_extend.js | 16 +++++++++++-- web/studio_queue.js | 15 ++++++++++++ web/updates.js | 4 ++-- 10 files changed, 133 insertions(+), 53 deletions(-) diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index b27a7be4..05749a41 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import test from 'node:test'; import {outputDownloadURL} from '../web/output_download.js'; -import {regenerateResult, upscaleResult} from '../web/studio_queue.js'; +import {regenerateResult, upscaleResult, createSecondPassQueue} from '../web/studio_queue.js'; import {referenceInputs} from '../web/prompt_references.js'; import {cleanOptionalPrompt, cleanOptionalWidgets} from '../web/optional_inputs.js'; @@ -36,7 +36,8 @@ test('main browser entry registers both views and preserves node hooks', async t const document = {head: new Element('head'), createElement: tag => new Element(tag), createRange: () => ({createContextualFragment: () => new Element('span')})}; const dependencies = { - app, api, outputDownloadURL, regenerateResult, upscaleResult, referenceInputs, cleanOptionalPrompt, cleanOptionalWidgets, + app, api, outputDownloadURL, regenerateResult, upscaleResult, createSecondPassQueue, referenceInputs, cleanOptionalPrompt, cleanOptionalWidgets, + secondPassAction: () => new Element('button'), createErrorPanel: () => ({element: new Element('error'), show() {}, clear() {}}), errorText: value => String(value), openStudio: node => opened.push(node), @@ -96,6 +97,8 @@ test('main browser entry registers both views and preserves node hooks', async t .replace("import { rememberPromptDraft, savePromptDraft, trackPromptDraft, watchPromptHistory, promptHistoryRestoring } from './prompt_draft.js';", 'const {rememberPromptDraft,savePromptDraft} = globalThis.__freevideoEntryTest; const trackPromptDraft = () => {}; const watchPromptHistory = () => {}; const promptHistoryRestoring = () => false;') .replace("import { cleanOptionalWidgets, cleanOptionalPrompt } from './optional_inputs.js';", 'const {cleanOptionalWidgets,cleanOptionalPrompt} = globalThis.__freevideoEntryTest;') .replace("import { regenerateResult, upscaleResult } from './studio_queue.js';", 'const {regenerateResult,upscaleResult} = globalThis.__freevideoEntryTest;') + .replace("import { regenerateResult, createSecondPassQueue } from './studio_queue.js';", 'const {regenerateResult,createSecondPassQueue} = globalThis.__freevideoEntryTest;') + .replace("import { secondPassAction } from './result_actions.js';", 'const {secondPassAction} = globalThis.__freevideoEntryTest;') .replace("import { openImageEditor, icon } from './image_editor.js';", 'const {openImageEditor,icon} = globalThis.__freevideoEntryTest;') .replace("import { installNavigation, refreshNavigation, preferredView } from './view_navigation.js';", 'const {installNavigation,refreshNavigation,preferredView} = globalThis.__freevideoEntryTest;') .replace("import { imageGenerationSupported } from './image_generation.js';", 'const imageGenerationSupported = () => true;') diff --git a/web/freevideo.js b/web/freevideo.js index 7f8d40e8..95a02ab8 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -15,7 +15,8 @@ import { shareButton } from './share.js'; import { reportFileName } from './report_issue.js'; import { rememberPromptDraft, savePromptDraft, trackPromptDraft, watchPromptHistory, promptHistoryRestoring } from './prompt_draft.js'; import { cleanOptionalWidgets, cleanOptionalPrompt } from './optional_inputs.js'; -import { regenerateResult, upscaleResult } from './studio_queue.js'; +import { regenerateResult, createSecondPassQueue } from './studio_queue.js'; +import { secondPassAction } from './result_actions.js'; import { openImageEditor, icon } from './image_editor.js'; import { imageGenerationSupported } from './image_generation.js'; @@ -511,14 +512,10 @@ function resultPanel(node) { links.append(shareButton(value, text)); links.append(el("span", value.result_cache_hit ? text("Reused previous result", "已复用上次结果") : value.conditioning_cache_hit ? text("Input cache reused", "已复用输入缓存") : text("Inputs encoded", "已编码输入"), "fv-mode")); if (value.preview) { - const upscale = el('button', text('Run second pass', '继续二采')); upscale.type = 'button'; - upscale.title = text('Continue from this preview: latent upscale, the second pass and a full-resolution decode.', '从这个预览接着跑:潜空间放大、二采,再以全分辨率解码。'); - upscale.onclick = async () => { - upscale.disabled = true; - try { await node.freevideoUpscaleResult(value); upscale.textContent = text('Queued', '已加入队列'); } - catch (error) { upscale.disabled = false; node.freevideoShowFailure(error); } - }; - links.append(upscale); + links.append(secondPassAction(text, async () => { + try { await node.freevideoUpscaleResult(value); } + catch (error) { node.freevideoShowFailure(error); throw error; } + })); } if (value.result_cache_hit) { const again = el('button', text('Regenerate', '重新生成')); again.type = 'button'; @@ -548,12 +545,7 @@ function resultPanel(node) { : text("VRAM: engine allocator peak. RAM: measured request processes, ", "显存为引擎分配器峰值,内存为请求进程实测,")) + (value.ram_metric || "unknown"); fitPanel(); }; - node.freevideoUpscaleResult = async value => { - if (node.freevideoUpscaling) return; - node.freevideoUpscaling = true; - try { return await upscaleResult(api, node.id, value, async () => cleanOptionalPrompt(await app.graphToPrompt())); } - finally { node.freevideoUpscaling = false; } - }; + node.freevideoUpscaleResult = createSecondPassQueue(api, () => node.id, async () => cleanOptionalPrompt(await app.graphToPrompt())); node.freevideoRegenerateResult = async value => { if (node.freevideoRegenerating) return; node.freevideoRegenerating = true; diff --git a/web/library.js b/web/library.js index b79f229a..90f4298c 100644 --- a/web/library.js +++ b/web/library.js @@ -1,7 +1,7 @@ import { api } from '../../scripts/api.js'; import { closeDialog } from './motion.js'; import { resultActions, previewBadge } from './result_actions.js'; -import { extendSourceExists } from './studio_extend.js'; +import { videoUnavailableText } from './studio_extend.js'; import { topDialog } from './dialog_keys.js'; const style = document.createElement('link'); @@ -138,11 +138,10 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu media.append(player); if (row.preview) media.append(previewBadge(row, t)); frame.append(media); - player.addEventListener('error', async () => { - if (!disposed && selected === row.video) { - const note = el('p', t('This video is unavailable. Refresh if it was moved.', '视频暂时无法打开,若文件已移动请刷新。'), 'fv-library-message'); + player.addEventListener('error', event => { + if (!disposed && selected === row.video && event.currentTarget === player) { + const note = el('p', videoUnavailableText(t), 'fv-library-message'); frame.replaceChildren(note); - if (!await extendSourceExists(api, row) && selected === row.video) links.querySelector('.fv-action-extend')?.remove(); } }, {once: true}); } @@ -157,7 +156,12 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu if (!isCurrent()) return; player?.pause(); const run = extend || (async (record, options) => (await import('./studio.js')).openExtendEditor(record, undefined, options)); - try { const editor = await run(saved, {isCurrent}); if (editor && !disposed) { dialog.close(); editor.element?.focus({preventScroll: true}); } } + try { + const editor = await run(saved, {isCurrent}); + if (!editor && isCurrent()) throw new Error(t('Could not open the editor. Try again.', '无法打开剪辑,请重试。')); + if (editor && !disposed) { dialog.close(); editor.element?.focus({preventScroll: true}); } + return editor; + } catch (error) { notice.textContent = error.message; } }, secondPass: row.preview && upscale ? () => secondPass(row) : null, @@ -189,9 +193,9 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu notice.textContent = ''; try { await upscale(row); - notice.textContent = t('Queued. The finished video appears here when the second pass is done.', '已加入队列,二采完成后成片会出现在作品里。'); + if (!disposed && selected === row.video) notice.textContent = t('Queued. It appears in "Creations" when done.', '已加入队列,完成后在“作品”里。'); } catch (error) { - notice.textContent = t('Could not start the second pass: ', '无法开始二采:') + error.message; + if (!disposed && selected === row.video) notice.textContent = t('Could not start the second pass: ', '无法开始二采:') + error.message; throw error; } } diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 8cd3c68b..ab7e1909 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -151,6 +151,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText redo.title = versions.value.rewritten ? t('Rewrite again', '重新改写') : t('Rewrite', '改写'); redo.setAttribute('aria-label', redo.title); cancel.hidden = !running || install.hidden === false; cancel.disabled = cancelling; + cancel.title = cancelling ? t('Cancelling…', '正在取消…') : ''; element.dataset.busy = String(Boolean(running)); element.dataset.enabled = String(Boolean(versions.value.enabled)); } @@ -171,7 +172,9 @@ export function createPromptEnhancer({node, input, editor, api, context, setText } async function cancelJob() { cancelling = true; consent?.(false); consent = null; render(); - if (job) await post('cancel', {job}); + const request = running; + try { if (job) await post('cancel', {job}); } + catch (error) { if (running === request) { cancelling = false; render(); } throw error; } } cancel.onclick = () => cancelJob().catch(error => { show(errorText(error), 'error'); }); async function poll(action, body) { @@ -181,7 +184,9 @@ export function createPromptEnhancer({node, input, editor, api, context, setText if (disposed || cancelling) await post('cancel', {job}); try { for (;;) { + if (disposed || cancelling) throw new Error('cancelled'); const row = await post('status', {job}); + if (disposed || cancelling) throw new Error('cancelled'); if (row.rewrite_report_id) { node.properties.freevideo_prompt_report = row.rewrite_report_id; node.graph?.change(); @@ -215,12 +220,14 @@ export function createPromptEnhancer({node, input, editor, api, context, setText waiting = true; show(errorText({message: 'prompt_required'}), 'note'); input.focus(); throw new Error('prompt_required'); } + cancelling = false; const execute = async () => { - cancelling = false; + if (disposed || cancelling) throw new Error('cancelled'); versions.value.useOriginal = false; const scene = context(), key = versions.key(scene), original = versions.value.original, edits = versions.value.edits; const reply = await api.fetchApi('/freevideo/prompt-vlm'); const info = await reply.json(); + if (disposed || cancelling) throw new Error('cancelled'); if (!reply.ok) throw new Error(info.error); token = info.token; if (info.ram_bytes) ramBytes = info.ram_bytes; @@ -246,7 +253,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText running = Promise.resolve().then(execute).catch(error => { if (!disposed) show(errorText(error), error?.message === 'cancelled' ? '' : 'error'); throw error; - }).finally(() => { running = null; install.hidden = true; render(); }); + }).finally(() => { running = null; cancelling = false; install.hidden = true; render(); }); render(); return running; } diff --git a/web/result_actions.js b/web/result_actions.js index 2bbe5f2e..e0562482 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -58,16 +58,20 @@ function popover(menu, summary, onClose = () => {}) { } // The next step of a first-pass preview. `run` reports its own errors and throws. -function secondPassAction(t, run) { +export function secondPassAction(t, run) { const button = action('button', t('Run second pass', '继续二采'), 'expand', 'fv-action-primary fv-action-second-pass'); const label = button.lastElementChild; button.title = t('Continue from this preview: latent upscale, the second pass and a full-resolution decode.', '从这个预览接着跑:潜空间放大、二采,再以全分辨率解码。'); button.onclick = async () => { if (button.disabled) return; - button.disabled = true; - try { await run(); label.textContent = t('Queued', '已加入队列'); } - catch { button.disabled = false; } + button.disabled = true; button.setAttribute('aria-busy', 'true'); + label.textContent = button.title = t('Adding…', '正在添加…'); + try { await run(); label.textContent = button.title = t('Queued', '已加入队列'); } + catch { + button.disabled = false; + label.textContent = button.title = t('Second pass did not start · Retry', '二采没有开始 · 重试'); + } finally { button.removeAttribute('aria-busy'); } }; return button; } @@ -117,14 +121,23 @@ export function resultActions(record, t, {diagnostic = null, secondPass = null, } if (extend && canExtend(record)) { const button = action('button', extendText(t, 'action'), 'extend', 'fv-action-secondary fv-action-extend'); - button.hidden = true; - extendSourceExists(api, record).then(exists => { button.hidden = !exists; }); + const label = button.lastElementChild; + const available = exists => { + label.textContent = exists ? extendText(t, 'action') : t('Video unavailable · retry', '视频不可用 · 重试'); + button.title = ''; + }; + extendSourceExists(api, record).then(available); button.onclick = async () => { if (button.disabled) return; button.disabled = true; + button.title = t('Checking video…', '正在检查视频…'); try { - if (!await extendSourceExists(api, record)) { button.hidden = true; return; } - await extend(record); + const exists = await extendSourceExists(api, record); available(exists); + if (!exists) return; + if (await extend(record) === null) throw new Error(t('Could not open the editor. Try again.', '无法打开剪辑,请重试。')); + } catch (error) { + label.textContent = error.message || t('Could not open the editor. Try again.', '无法打开剪辑,请重试。'); + button.title = label.textContent; } finally { button.disabled = false; } }; bar.append(button); diff --git a/web/share.js b/web/share.js index 622e90ac..ddc034a9 100644 --- a/web/share.js +++ b/web/share.js @@ -3,6 +3,7 @@ import {closeDialog} from './motion.js'; import {effortFor, effortName} from './sampling_effort.js'; import {fontCSS as bundledFontCSS, fontLoaded, loadFont} from './fonts.js'; import {shareLayout} from './share_layout.js'; +import {videoUnavailableText} from './studio_extend.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;}; @@ -95,7 +96,7 @@ export async function openShare(record,t){ const tabs=el('div','fv-share-tabs');tabs.setAttribute('role','tablist'); const indicator=el('span','fv-share-indicator');indicator.setAttribute('aria-hidden','true');tabs.append(indicator); const slide=()=>{const b=tabs.querySelector('[aria-selected=true]');if(b)indicator.style.cssText=`transform:translateX(${b.offsetLeft-3}px);width:${b.offsetWidth}px`;}; - let kind='image',ready=false,busy=false,disposed=false,metadata,shape,template,first,logo,exported; + let kind='image',ready=false,busy=false,disposed=false,videoFailed=false,metadata,shape,template,first,logo,exported; let exportRequest; const abort=new AbortController(); const panel=el('div','fv-share-body'),preview=el('div','fv-share-preview'),canvas=el('canvas'),player=el('video'); @@ -108,16 +109,25 @@ export async function openShare(record,t){ const save=button(t('Save image','保存分享图'),download,'fv-primary');save.disabled=true; const cancel=button(t('Cancel','取消'),()=>exportRequest?.abort());cancel.hidden=true; footer.append(message,cancel,save);heading.append(title,tabs,close);dialog.append(heading,panel,footer); + const videoError=()=>{ + if(disposed)return; + videoFailed=true; + if(kind==='video'&&!busy){message.textContent=videoUnavailableText(t);dialog.dataset.error='true';} + }; + player.addEventListener('error',videoError); const choose=next=>{ + if(busy)return; kind=next;tabs.querySelectorAll('button').forEach(b=>{ b.setAttribute('aria-selected',String(b.dataset.kind===kind));b.tabIndex=b.dataset.kind===kind?0:-1; }); save.textContent=kind==='image'?t('Save image','保存分享图'):t('Save video','保存分享视频'); slide(); if(!ready)return; + message.textContent=kind==='video'&&videoFailed?videoUnavailableText(t):''; + if(kind==='video'&&videoFailed)dialog.dataset.error='true';else delete dialog.dataset.error; drawShareCard(canvas,kind==='image'?first:null,logo,metadata,t); player.hidden=kind!=='video'; - if(kind==='video')player.play().catch(()=>{});else player.pause(); + if(kind==='video')player.play().catch(error=>{if(!['AbortError','NotAllowedError'].includes(error.name))videoError();});else player.pause(); }; for(const [name,label] of [['image',t('Image','图片')],['video',t('Video','视频')]]){ const b=button(label,()=>choose(name));b.dataset.kind=name;b.setAttribute('role','tab');b.setAttribute('aria-selected',String(name===kind));b.tabIndex=name===kind?0:-1;tabs.append(b); @@ -129,13 +139,22 @@ export async function openShare(record,t){ }; async function download(){ if(!ready||busy)return; - if(kind==='image'){ - canvas.toBlob(async blob=>{if(!blob||disposed)return;blob=await withGraph(blob,metadata?.graph);const url=URL.createObjectURL(blob);const a=el('a');a.href=url;a.download=`FreeVideo_share_${id.split('/')[1].slice(0,12)}.png`;a.click();setTimeout(()=>URL.revokeObjectURL(url),60000);},'image/png');return; - } - busy=true;save.disabled=true;cancel.hidden=false;tabs.querySelectorAll('button').forEach(b=>b.disabled=true); - message.textContent=t('Preparing sharing video…','正在导出分享视频…');dialog.dataset.busy='true'; + busy=true;save.disabled=true;cancel.hidden=kind==='image';tabs.querySelectorAll('button').forEach(b=>b.disabled=true); + message.textContent=kind==='image'?t('Saving the share image…','正在保存分享图…'):t('Exporting the share video…','正在导出分享视频…'); + dialog.dataset.busy='true';delete dialog.dataset.error; exportRequest=new AbortController(); try{ + if(kind==='image'){ + let blob=await new Promise((resolve,reject)=>{ + canvas.toBlob(value=>value?resolve(value):reject(new Error('image_encoding_failed')),'image/png'); + }); + if(disposed)return; + blob=await withGraph(blob,metadata?.graph); + if(disposed)return; + const url=URL.createObjectURL(blob),a=el('a'); + a.href=url;a.download=`FreeVideo_share_${id.split('/')[1].slice(0,12)}.png`;a.click(); + setTimeout(()=>URL.revokeObjectURL(url),60000);message.textContent='';return; + } if(!exported){ const response=await api.fetchApi('/freevideo/share/video',{method:'POST',headers:{'Content-Type':'application/json'},signal:exportRequest.signal, body:JSON.stringify({id,template,rect:shape.rect})}); @@ -145,7 +164,11 @@ export async function openShare(record,t){ } if(disposed)return; const a=el('a');a.href=api.apiURL('/freevideo/share/video?'+new URLSearchParams(exported));a.download='';a.click();message.textContent=''; - }catch(error){if(!disposed)message.textContent=error.name==='AbortError'?'':error.message;} + }catch(error){if(!disposed){ + message.textContent=kind==='image'?t('Could not save the share image. Try again.','分享图没有保存成功,请重试。') + :error.name==='AbortError'?'':error.message; + if(message.textContent)dialog.dataset.error='true'; + }} finally{busy=false;if(!disposed){save.disabled=false;cancel.hidden=true;delete dialog.dataset.busy;tabs.querySelectorAll('button').forEach(b=>b.disabled=false);}} } dialog.addEventListener('cancel',event=>{event.preventDefault();closeDialog(dialog);}); @@ -162,6 +185,6 @@ export async function openShare(record,t){ shape=drawShareCard(canvas,null,logo,metadata,t);template=canvas.toDataURL('image/png').split(',')[1]; preview.style.aspectRatio=`${shape.width}/${shape.height}`; player.style.cssText=`left:${shape.rect[0]/shape.width*100}%;top:0;width:${shape.rect[2]/shape.width*100}%;height:${shape.rect[3]/shape.height*100}%`; - ready=true;choose(kind);save.disabled=false;message.textContent=''; + ready=true;choose(kind);save.disabled=false; }catch(error){if(!disposed){message.textContent=error.message||t('Preview unavailable. Close and try again.','预览暂时无法读取,请关闭后重试。');dialog.dataset.error='true';}} } diff --git a/web/studio.js b/web/studio.js index 83c9ec79..11b9f6f3 100644 --- a/web/studio.js +++ b/web/studio.js @@ -31,7 +31,7 @@ import { createFirstRunGuide } from './first_run_guide.js'; import { createStudioLength, savedSeconds } from './studio_length.js?v=20261011-length'; import { firstFrameRow } from './first_frame_row.js'; import { createStudioChain, chainPosition, readChainResult, sumPartEstimates, singleRunSnapshot } from './studio_chain.js?v=20261011-length'; -import { createExtendState, extendText, extendSourceExists, readExtendRecord, extendSnapshot, mountExtendThumbnail } from './studio_extend.js'; +import { createExtendState, extendText, extendSourceExists, readExtendRecord, extendSnapshot, mountExtendThumbnail, videoUnavailableText } from './studio_extend.js'; const languageOverride = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('freevideo_lang') : null; @@ -330,7 +330,15 @@ export async function extendVideo(record, node = current?.node || app.graph?._no export async function openExtendEditor(record, node = current?.node || app.graph?._nodes?.find(row => row.type === 'FreeVideoGenerate'), {studio = current?.node === node ? current?.dialog : undefined, isCurrent} = {}) { - if (!node || isCurrent?.() === false) return null; + if (isCurrent?.() === false) return null; + if (!node || !app.graph?._nodes?.includes(node)) { + node = app.graph?._nodes?.find(row => row.type === 'FreeVideoGenerate'); + if (!node) { + node = globalThis.LiteGraph?.createNode('FreeVideoGenerate'); + if (!node || !app.graph) throw new Error(t('Could not add the creation node. Try again.', '无法添加创作节点,请重试。')); + app.graph.add(node); app.graph.change?.(); + } + } const handle = await openEditorView(node, {studio, work: record, extend: true, isCurrent}); await handle?.ready; return handle; @@ -819,8 +827,11 @@ export function openStudio(node, {extendFrom = null} = {}) { progress.updateReport({report_id: node.freevideoReportId}); stage.querySelector('video')?.pause(); stageMedia.replaceChildren(); const video = el('video'); video.src = view(r.video, 'output') + (r.version ? `&fv=${encodeURIComponent(r.version)}` : ''); video.controls = true; video.preload = 'metadata'; video.playsInline = true; stageMedia.append(video); - video.addEventListener('error', async () => { - if (!await extendSourceExists(api, r) && result === r) links.querySelector('.fv-action-extend')?.remove(); + video.addEventListener('error', () => { + if (!disposed && result === r && video.isConnected) { + stageMedia.replaceChildren(el('p', videoUnavailableText(t), 'fv-note fv-video-problem')); + if (r.preview) stageMedia.append(previewBadge(r, t)); + } }, {once: true}); // A preview is marked on the picture itself; its timings describe only // the half-resolution first pass, so they stay in the report. diff --git a/web/studio_extend.js b/web/studio_extend.js index 840a5e41..a8b1dfc7 100644 --- a/web/studio_extend.js +++ b/web/studio_extend.js @@ -25,10 +25,22 @@ export function extendVideoURL(record) { export async function extendSourceExists(api, record) { if (!canExtend(record)) return false; - try { return (await api.fetchApi(extendVideoURL(record), {method: 'HEAD'})).ok; } - catch { return false; } + const url = extendVideoURL(record); + try { + if ((await api.fetchApi(url, {method: 'HEAD', signal: AbortSignal.timeout(10000)})).ok) return true; + } catch { /* HEAD can fail even when the browser can still read the video. */ } + try { + const response = await api.fetchApi(url, {method: 'GET', headers: {Range: 'bytes=0-0'}, + signal: AbortSignal.timeout(10000)}); + // A server may ignore Range. Release its stream without downloading the video. + await response.body?.cancel().catch(() => {}); + return response.ok; + } catch { return false; } } +export const videoUnavailableText = t => t('This video cannot be opened. If you moved the file, refresh the page.', + '视频打不开,如果移动过文件,请刷新页面。'); + export async function readExtendRecord(api, record) { if (record.geometry?.width && record.geometry?.height && (record.geometry.seconds || record.geometry.frames)) return record; let before = null; diff --git a/web/studio_queue.js b/web/studio_queue.js index 38b41f96..25a7bd74 100644 --- a/web/studio_queue.js +++ b/web/studio_queue.js @@ -60,6 +60,21 @@ export async function upscaleResult(api, nodeId, result, currentPrompt) { return reply.prompt_id; } +// Each preview owns its submission outcome; another preview waits its turn. +// Only duplicate clicks for the same pending preview share a promise. +export function createSecondPassQueue(api, nodeId, currentPrompt) { + const pending = new Map(); + let tail = Promise.resolve(); + return result => { + const key = result?.video; + if (pending.has(key)) return pending.get(key); + const request = tail.catch(() => {}).then(() => upscaleResult(api, typeof nodeId === 'function' ? nodeId() : nodeId, result, currentPrompt)) + .finally(() => { pending.delete(key); }); + pending.set(key, request); tail = request; + return request; + }; +} + export function randomSeed() { const bytes = crypto.getRandomValues(new Uint32Array(2)); return (bytes[0] & 0x1fffff) * 0x100000000 + bytes[1]; diff --git a/web/updates.js b/web/updates.js index b40b70d5..4daef651 100644 --- a/web/updates.js +++ b/web/updates.js @@ -168,7 +168,7 @@ export function createUpdateNotice(cn) { } else if (failure) { text = t('Open the FreeVideo launcher to update, or download the new version.', '请在 FreeVideo 启动器中更新,或下载新版。'); actions = true; - } else if (candidate && dismissed !== identity(candidate)) { + } else if (candidate) { text = t('FreeVideo update available', 'FreeVideo 有新版本') + ' · ' + displayVersion(candidate); if (launcher?.status === 'error' && launcher.error) text += ' · ' + t('last attempt failed', '上次更新未完成'); actions = true; @@ -184,7 +184,7 @@ export function createUpdateNotice(cn) { const direct = !!launcher && !launcher.manual && !failure; apply.hidden = !actions || !direct; link.hidden = !actions || direct; - dismiss.hidden = !actions; + dismiss.hidden = !actions || dismissed === identity(candidate); stop.hidden = !(busy && phase === 'waiting' && !offline); }; listeners.add(render); render(); From 4865efbd7d30f890b7b7932a80320314a499d0ca Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:09:59 +0000 Subject: [PATCH 23/53] Creation panel asks for empty duration, size, seed or steps instead of reusing old values --- web/editor_first_frame.js | 4 +-- web/first_frame_row.js | 7 +++- web/prompt_enhance.js | 7 ++-- web/sampling_effort.js | 1 + web/studio.js | 71 +++++++++++++++++++++++++++++---------- web/studio_length.js | 5 +-- 6 files changed, 71 insertions(+), 24 deletions(-) diff --git a/web/editor_first_frame.js b/web/editor_first_frame.js index 6bd31f68..f2ae48a8 100644 --- a/web/editor_first_frame.js +++ b/web/editor_first_frame.js @@ -6,7 +6,7 @@ export const firstFrameOn = record => record.settings?.[FIRST_FRAME_KEY] === tru // actions.firstFrameVisible(): the selection continues earlier footage and this machine can; // actions.setFirstFrame(value): saves the choice with the project. export function createFirstFrameSwitch(host, {state, t, actions}) { - const {row, input} = firstFrameRow(t, 'fv-ed-first-frame'); + const {row, input, setDisabled} = firstFrameRow(t, 'fv-ed-first-frame'); host.append(row); let shown = false; // While generating the row stays as it was and cannot be changed. @@ -14,7 +14,7 @@ export function createFirstFrameSwitch(host, {state, t, actions}) { if (!busy) shown = !!actions.firstFrameVisible?.(); row.hidden = !shown; input.checked = firstFrameOn(state.record); - input.disabled = busy; + setDisabled(busy); } input.onchange = () => { try { actions.setFirstFrame(input.checked); } catch (error) { actions.error?.(error); } finally { input.checked = firstFrameOn(state.record); } }; update(); diff --git a/web/first_frame_row.js b/web/first_frame_row.js index 0de03ace..d95081e6 100644 --- a/web/first_frame_row.js +++ b/web/first_frame_row.js @@ -7,5 +7,10 @@ export function firstFrameRow(t, className) { const input = el('input'); input.type = 'checkbox'; input.setAttribute('role', 'switch'); input.setAttribute('aria-label', t('First-frame reference', '首帧参考')); row.append(input); - return {row, input}; + const purpose = row.title; + return {row, input, setDisabled(disabled) { + input.disabled = disabled; + input.title = ''; + row.title = disabled ? '' : purpose; + }}; } diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index ab7e1909..1af8c72f 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -75,6 +75,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText toggle.setAttribute('aria-label', t('Prompt enhancement', '提示词增强')); label.append(toggle, el('span', t('FreeToken enhancement', 'FreeToken 提示词增强'))); label.title = t('Based on FreeToken. Rewrite locally with reference images, following MiniMax H3 guidelines.', '基于 FreeToken,结合参考图,在本机按 MiniMax H3 规则改写。'); + const enhancementHint = label.title; const tabs = el('div', null, 'fv-enhance-tabs'); tabs.setAttribute('role', 'group'); tabs.setAttribute('aria-label', t('Prompt version', '提示词版本')); const buttons = {}; for (const [name, text] of [['original', t('Original', '原文')], ['rewritten', t('Enhanced', '改写')]]) { @@ -142,14 +143,16 @@ export function createPromptEnhancer({node, input, editor, api, context, setText } function render() { toggle.checked = Boolean(versions.value.enabled); + label.title = input.disabled ? '' : enhancementHint; tabs.hidden = !versions.value.rewritten; for (const [name, b] of Object.entries(buttons)) { b.setAttribute('aria-pressed', String(versions.value.selected === name)); b.disabled = input.disabled || (name === 'rewritten' && !versions.value.enabled); } redo.hidden = !versions.value.enabled || Boolean(running); redo.disabled = input.disabled; - redo.title = versions.value.rewritten ? t('Rewrite again', '重新改写') : t('Rewrite', '改写'); - redo.setAttribute('aria-label', redo.title); + const rewriteLabel = versions.value.rewritten ? t('Rewrite again', '重新改写') : t('Rewrite', '改写'); + redo.title = input.disabled ? '' : rewriteLabel; + redo.setAttribute('aria-label', rewriteLabel); cancel.hidden = !running || install.hidden === false; cancel.disabled = cancelling; cancel.title = cancelling ? t('Cancelling…', '正在取消…') : ''; element.dataset.busy = String(Boolean(running)); diff --git a/web/sampling_effort.js b/web/sampling_effort.js index aaac9060..d7360286 100644 --- a/web/sampling_effort.js +++ b/web/sampling_effort.js @@ -109,6 +109,7 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { if (isCustom) rail.setAttribute('aria-valuetext', custom.textContent); rail.title = disabled ? state.reason || t('Controlled by connected nodes.', '由连接的节点控制。') : t('Same model. Higher effort uses more sampling steps; results vary by scene.', '使用同一模型,提高档位会增加采样步数;效果因场景而异。'); + for (const name of nameButtons) { name.disabled = disabled; name.title = ''; } } return {element, update, setEstimate(text, title = '') { estimate.textContent = text; estimate.title = title; }, dispose: cancel}; } diff --git a/web/studio.js b/web/studio.js index 11b9f6f3..b8de2a38 100644 --- a/web/studio.js +++ b/web/studio.js @@ -394,7 +394,8 @@ export function openStudio(node, {extendFrom = null} = {}) { const references = attachReferencePicker(prompt, {items: () => referenceItems(node), t, view, host: dialog, addMedia: () => { mediaDetails.open = true; mediaDetails.scrollIntoView({block: 'nearest', behavior: 'smooth'}); mediaMount.querySelector('button')?.focus(); }}); const mention = button('@', () => references.open(), 'fv-reference-trigger'); - mention.title = t('Reference media (@)', '引用素材(@)'); mention.setAttribute('aria-label', mention.title); + mention.setAttribute('aria-label', t('Reference media (@)', '引用素材(@)')); + mention.title = mention.getAttribute('aria-label'); mention.disabled = prompt.disabled; mention.onpointerdown = e => e.preventDefault(); promptEditor.append(mention); cleanup.push(() => references.dispose()); const enhancer = createPromptEnhancer({node, input: prompt, editor: promptEditor, api, t, starter, @@ -440,7 +441,7 @@ export function openStudio(node, {extendFrom = null} = {}) { mp.value = sizeSelection.value; const legacyOption = [...mp.options].find(o => Number(o.value) === 1); mp.title = t('1 MP = 1024 × 1024, as in ComfyUI.', '1 MP = 1024 × 1024,与 ComfyUI 一致。'); mp.disabled = dimensionsLinked; - const seconds = el('input'); seconds.type = 'number'; seconds.min = '1.625'; seconds.step = 'any'; seconds.value = savedSeconds(node, value(node, 'seconds')); seconds.disabled = linked(node, 'seconds'); seconds.setAttribute('aria-label', t('Duration in seconds', '时长(秒)')); + const seconds = el('input'); seconds.type = 'number'; seconds.required = true; seconds.min = '1.625'; seconds.step = 'any'; seconds.value = savedSeconds(node, value(node, 'seconds')); seconds.disabled = linked(node, 'seconds'); seconds.setAttribute('aria-label', t('Duration in seconds', '时长(秒)')); const fields = el('div', null, 'fv-fields'); fields.append(field(t('Total pixels', '总像素'), mp), field(t('Duration · seconds', '时长 · 秒'), seconds)); canvas.append(fields); const canvasNote = el('div', null, 'fv-canvas-note'), dimensions = el('span'), duration = el('span'); canvasNote.append(dimensions, duration); canvas.append(canvasNote); const rangeMount = el('div'); canvas.append(rangeMount); @@ -463,6 +464,7 @@ export function openStudio(node, {extendFrom = null} = {}) { }); passOptions[2].title = t('Two-pass, paused after the half-resolution first pass so you can look at it. Run the second pass from the result when it looks right: the video matches generating it directly.', '二次采样跑完半分辨率的一采先停下,给你看预览;满意后在结果里点「继续二采」,成片与直接生成一致。'); + const previewPassHint = passOptions[2].title; passMode.append(...passOptions); passMode.addEventListener('keydown', event => { const step = {ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1}[event.key]; @@ -495,13 +497,15 @@ export function openStudio(node, {extendFrom = null} = {}) { } function syncPassMode() { const mode = !twoPass.checked ? 'off' : previewFirst.checked ? 'preview' : 'on'; + const passLinked = linked(node, 'two_pass'), previewLinked = linked(node, 'preview'); for (const option of passOptions) { const selected = option.dataset.mode === mode; option.setAttribute('aria-checked', String(selected)); option.tabIndex = selected ? 0 : -1; + option.title = !(passLinked || (option.dataset.mode === 'preview' && previewLinked)) && option.dataset.mode === 'preview' ? previewPassHint : ''; } - passOptions[0].disabled = Boolean(extension.source) || linked(node, 'two_pass') || !widget(node, 'two_pass'); + passOptions[0].disabled = Boolean(extension.source) || passLinked || !widget(node, 'two_pass'); passOptions[1].disabled = twoPass.disabled; - passOptions[2].disabled = Boolean(extension.source) || twoPass.disabled || linked(node, 'preview') || !widget(node, 'preview'); + passOptions[2].disabled = Boolean(extension.source) || twoPass.disabled || previewLinked || !widget(node, 'preview'); passOptions[2].hidden = !widget(node, 'preview'); } samplingSettings.append(effort.element, twoPassLabel); @@ -532,7 +536,17 @@ export function openStudio(node, {extendFrom = null} = {}) { const [, advanced] = expand(t('Advanced', '高级选项')); const custom = el('div', null, 'fv-fields'); const width = el('input'), height = el('input'); - for (const [w, name, label] of [[width, 'width', t('Width', '宽度')], [height, 'height', t('Height', '高度')]]) { w.type = 'number'; w.min = '256'; w.max = '4096'; w.step = '32'; w.value = value(node, name); w.disabled = dimensionsLinked; w.setAttribute('aria-label', label); custom.append(field(label, w)); w.onchange = () => { if (w.value && w.reportValidity()) { set(node, name, Number(w.value)); selected = 'custom'; node.properties.freevideo_aspect = selected; ratio = Number(width.value) / Number(height.value); updateCanvas(); } }; } + for (const [input, name, label] of [[width, 'width', t('Width', '宽度')], [height, 'height', t('Height', '高度')]]) { + input.type = 'number'; input.required = true; input.min = '256'; input.max = '4096'; input.step = '32'; + input.value = value(node, name); input.disabled = dimensionsLinked; input.setAttribute('aria-label', label); + custom.append(field(label, input)); + input.onchange = () => { + if (!input.value || !input.reportValidity()) return; + set(node, name, Number(input.value)); selected = 'custom'; node.properties.freevideo_aspect = selected; + ratio = Number(value(node, 'width')) / Number(value(node, 'height')); + updateCanvas(); + }; + } advanced.append(custom); lengthControl = createStudioLength({api, node, seconds, advanced, fields, t, field, setSeconds: seconds => set(node, 'seconds', seconds), onChange: () => { if (runButtonReady) { updateRunButton(); showEffortEstimate(); updateDuration(); } }}); @@ -548,7 +562,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const plan = lengthControl.plan; if (plan || !lengthControl.needsPlan) manyParts = plan?.count > 1; firstFrame.row.hidden = !widget(node, 'first_frame_reference') || !(manyParts || extension.source); firstFrame.input.checked = value(node, 'first_frame_reference') === true; - firstFrame.input.disabled = Boolean(capturing || queueState?.submitting || queueState?.running || chainController?.active); + firstFrame.setDisabled(Boolean(capturing || queueState?.submitting || queueState?.running || chainController?.active)); } const samplingFields = el('div', null, 'fv-fields'); samplingFields.style.marginTop = '14px'; const baseSteps = el('input'), refineSteps = el('input'); @@ -689,7 +703,7 @@ export function openStudio(node, {extendFrom = null} = {}) { }, 220); } const seedFields = el('div', null, 'fv-fields'); seedFields.style.marginTop = '14px'; - const seed = el('input'); seed.type = 'number'; seed.min = '0'; seed.max = String(2 ** 53 - 1); seed.step = '1'; seed.value = value(node, 'seed'); seed.disabled = linked(node, 'seed'); seed.setAttribute('aria-label', t('Seed', '种子')); + const seed = el('input'); seed.type = 'number'; seed.required = true; seed.min = '0'; seed.max = String(2 ** 53 - 1); seed.step = '1'; seed.value = value(node, 'seed'); seed.disabled = linked(node, 'seed'); seed.setAttribute('aria-label', t('Seed', '种子')); seed.onchange = () => { if (seed.value && seed.reportValidity()) set(node, 'seed', Number(seed.value)); }; const mode = el('select'); for (const [v, label] of [['randomize', t('New each run', '每次随机')], ['fixed', t('Keep fixed', '保持固定')], ['increment', t('Increment', '递增')], ['decrement', t('Decrement', '递减')]]) { const o = el('option', label); o.value = v; mode.append(o); } mode.value = value(node, 'control_after_generate') || 'fixed'; mode.onchange = () => set(node, 'control_after_generate', mode.value); @@ -1109,7 +1123,19 @@ export function openStudio(node, {extendFrom = null} = {}) { // 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; + const invalid = [...dialog.querySelectorAll('input:not(:disabled)')].find(e => !e.checkValidity()); + if (invalid) { + const details = invalid.closest('details'); if (details) details.open = true; + if (invalid.validity.valueMissing) { + const message = new Map([[seconds, t('Fill in the duration.', '请填写时长。')], + [width, t('Fill in the width.', '请填写宽度。')], [height, t('Fill in the height.', '请填写高度。')], + [seed, t('Fill in the seed.', '请填写种子。')], + [baseSteps, t('Fill in the first-pass steps.', '请填写一采步数。')], + [refineSteps, t('Fill in the second-pass steps.', '请填写二采步数。')]]).get(invalid); + if (message) { status.dataset.error = 'true'; status.textContent = message; } + } + invalid.focus(); invalid.reportValidity(); return; + } if (!extension.source && !prompt.disabled && !prompt.value.trim() && !untouched()) { prompt.focus(); status.textContent = t('Describe your scene first.', '请先描述画面。'); return; } const chosenMode = runMode, batchCount = chosenMode === 'batch' ? Number(count.value) : 1; const originalSeed = value(node, 'seed'), seedMode = mode.value; @@ -1173,21 +1199,30 @@ export function openStudio(node, {extendFrom = null} = {}) { : rest ? t(`${minutes} min ${rest} s`, `${minutes} 分 ${rest} 秒`) : t(`${minutes} min`, `${minutes} 分钟`)} · 24 fps`; } function updateCanvas() { - width.value = extension.source?.geometry.width ?? value(node, 'width'); height.value = extension.source?.geometry.height ?? value(node, 'height'); - dimensionText(dimensions, dimensionsLinked && !extension.source ? t('Size from connected nodes', '尺寸来自已连接节点') : `${width.value} × ${height.value}`); + // Render committed dimensions without replacing either input draft. + // Duration/estimate refreshes and edits to the other dimension also + // come here; only an explicit size choice should fill both fields. + const w = extension.source?.geometry.width ?? value(node, 'width'); + const h = extension.source?.geometry.height ?? value(node, 'height'); + dimensionText(dimensions, dimensionsLinked && !extension.source ? t('Size from connected nodes', '尺寸来自已连接节点') : `${w} × ${h}`); updateDuration(); for (const b of shapes.children) b.setAttribute('aria-pressed', String(b.dataset.ratio === selected)); const shapeIndex = [...shapes.children].findIndex(b => b.dataset.ratio === selected); shapes.style.setProperty('--fv-shape-index', Math.max(0, shapeIndex)); shapes.dataset.custom = String(shapeIndex < 0); - if (!busy) previewSize(Number(width.value), Number(height.value)); + if (!busy) previewSize(Number(w), Number(h)); refreshEffortEstimate(); } + function syncCanvasSize() { + width.value = extension.source?.geometry.width ?? value(node, 'width'); + height.value = extension.source?.geometry.height ?? value(node, 'height'); + updateCanvas(); + } function applySize() { if (dimensionsLinked || extension.source) return; const [w, h] = canvasDimensions(pixels, ratio); if (w < 256 || h < 256 || w > 4096 || h > 4096) throw new Error(t('This aspect ratio and pixel count exceed the supported dimensions. Use custom dimensions.', '该比例与像素数超出尺寸范围,请使用自定义宽高。')); - set(node, 'width', w); set(node, 'height', h); node.properties.freevideo_aspect = selected; node.properties.freevideo_pixels = pixels; updateCanvas(); + set(node, 'width', w); set(node, 'height', h); node.properties.freevideo_aspect = selected; node.properties.freevideo_pixels = pixels; syncCanvasSize(); } async function inputRatio(required = false) { const rows = mediaNode ? JSON.parse(value(mediaNode, 'assets') || '[]') : []; @@ -1195,8 +1230,10 @@ export function openStudio(node, {extendFrom = null} = {}) { const source = active.find(r => r.role === 'first') || active.find(r => r.role === 'last') || active.find(r => /\.(png|jpe?g|webp|bmp|tiff?)$/i.test(r.file)); if (!source) { if (required) throw new Error(t('Add an image in Media to follow its aspect ratio.', '请先在素材中添加图片,再跟随输入比例。')); return; } const image = new Image(); image.src = view(source.file); - await image.decode(); - if (disposed || !image.naturalWidth || !image.naturalHeight) return; + const unreadable = () => new Error(t('Could not read the image dimensions. Choose another image.', '无法读取图片尺寸,请换一张图片。')); + try { await image.decode(); } catch { if (disposed) return; throw unreadable(); } + if (disposed) return; + if (!image.naturalWidth || !image.naturalHeight) throw unreadable(); if (selected === 'input') { ratio = image.naturalWidth / image.naturalHeight; applySize(); } } for (const [id, label, r] of [...frameShapes(t), ['input', t('Match image', '跟随图片'), null]]) { @@ -1205,7 +1242,7 @@ export function openStudio(node, {extendFrom = null} = {}) { b.append(el('span', null, 'fv-shape-icon'), el('span', label)); shapes.append(b); } mp.onchange = () => { try { pixels = Number(mp.value); applySize(); if (pixels !== 1) legacyOption?.remove(); } catch (error) { status.dataset.error = 'true'; status.textContent = error.message; } }; - seconds.onchange = () => { if (lengthControl.commit()) updateCanvas(); }; + seconds.onchange = () => { if (lengthControl.commit({report: false})) updateCanvas(); }; const mediaChanged = async e => { if (String(e.detail) !== String(mediaNode?.id)) return; refreshEffortEstimate(true); if (selected !== 'input') return; try { await inputRatio(); } catch (error) { status.textContent = error.message; } }; const lorasChanged = e => { if (String(e.detail) === String(loraNode?.id)) refreshEffortEstimate(true); }; window.addEventListener('freevideo-loras', lorasChanged); @@ -1273,7 +1310,7 @@ export function openStudio(node, {extendFrom = null} = {}) { pixels = point.pixels; mp.value = String(pixels); set(node, 'width', point.width); set(node, 'height', point.height); node.properties.freevideo_pixels = pixels; - seconds.value = point.seconds; set(node, 'seconds', point.seconds); updateCanvas(); + seconds.value = point.seconds; set(node, 'seconds', point.seconds); syncCanvasSize(); }}); rangeMount.append(capabilityView.element); cleanup.push(capabilityView.dispose); controls.addEventListener('change', () => capabilityView.update()); @@ -1305,7 +1342,7 @@ export function openStudio(node, {extendFrom = null} = {}) { twoPass.checked = Boolean(source) || value(node, 'two_pass') !== false; previewFirst.checked = !source && value(node, 'preview') === true; prompt.value = shown(value(node, 'text')); placeholder(); enhancer.sync(); - syncSamplingSteps(); updateCanvas(); seconds.dispatchEvent(new Event('input')); + syncSamplingSteps(); syncCanvasSize(); seconds.dispatchEvent(new Event('input')); prompt.focus({preventScroll: true}); } current.enterExtend = record => { diff --git a/web/studio_length.js b/web/studio_length.js index 747cd000..4d889fea 100644 --- a/web/studio_length.js +++ b/web/studio_length.js @@ -115,8 +115,9 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie }; const input = () => { submitted = false; planLimits = limits; edited = false; invalidate(); schedule(); }; seconds.addEventListener('input', input); - function commit() { - if (!seconds.value || !seconds.reportValidity()) return false; + function commit({report = true} = {}) { + const valid = report ? seconds.reportValidity() : seconds.checkValidity(); + if (!seconds.value || !valid) return false; const value = total() > 30 ? Math.round(total()) : total(); seconds.value = value; if (value > 60) remember(LENGTH_PROPERTIES.seconds, value); else if (node.properties && Object.hasOwn(node.properties, LENGTH_PROPERTIES.seconds)) { delete node.properties[LENGTH_PROPERTIES.seconds]; node.graph?.change(); } From 75fd979a6a020170e9ef3a3cc8d8e279a5767fa8 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:10:07 +0000 Subject: [PATCH 24/53] LoRA list keeps the three most recent at the top and adds Clear --- web/studio.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/web/studio.js b/web/studio.js index b8de2a38..1a8f2141 100644 --- a/web/studio.js +++ b/web/studio.js @@ -137,6 +137,16 @@ export function promptGuide() { return note; } +// The last three LoRAs chosen, kept in this browser. Names that are no longer in the folder are never offered. +const LORA_RECENT = 'freevideo_lora_recent', LORA_RECENT_MAX = 3; +const recentLoras = () => { + try { const names = JSON.parse(localStorage.getItem(LORA_RECENT) || '[]'); return Array.isArray(names) ? names.filter(n => typeof n === 'string').slice(0, LORA_RECENT_MAX) : []; } + catch { return []; } +}; +const rememberLora = name => { + if (!name) return; + try { localStorage.setItem(LORA_RECENT, JSON.stringify([name, ...recentLoras().filter(n => n !== name)].slice(0, LORA_RECENT_MAX))); } catch {} +}; export function loraPanel(node, panel, {promptNode = null} = {}) { const list = el('div', null, 'fv-lora-list'), note = el('p', '', 'fv-lora-note'); const actions = el('div', null, 'fv-lora-add'); panel.append(list, actions, note); @@ -187,7 +197,7 @@ export function loraPanel(node, panel, {promptNode = null} = {}) { if (rendering) { renderAgain = true; return; } rendering = true; try { - list.replaceChildren(); + list.replaceChildren(); clear.remove(); const rows = read(); syncPrompt(rows); rows.forEach((row, index) => { @@ -195,8 +205,9 @@ export function loraPanel(node, panel, {promptNode = null} = {}) { const enabled = el('input'); enabled.type = 'checkbox'; enabled.checked = row.enabled !== false; enabled.setAttribute('aria-label', t('Enable LoRA', '启用 LoRA')); enabled.onchange = () => { row.enabled = enabled.checked; write(rows); }; const select = el('select'); select.setAttribute('aria-label', t('LoRA model', 'LoRA 模型')); - for (const name of [...new Set([row.name, ...names])].filter(Boolean)) { const option = el('option', name); option.value = name; select.append(option); } - select.value = row.name; select.title = row.name; select.onchange = () => { row.name = select.value; write(rows); }; + const pinned = recentLoras().filter(n => names.includes(n)); + for (const name of [...new Set([...pinned, ...names, row.name])].filter(Boolean)) { const option = el('option', name); option.value = name; select.append(option); } + select.value = row.name; select.title = row.name; select.onchange = () => { row.name = select.value; rememberLora(row.name); write(rows); }; const strength = el('input'); strength.type = 'number'; strength.min = '-4'; strength.max = '4'; strength.step = '.05'; strength.value = row.strength ?? 1; strength.setAttribute('aria-label', t('LoRA strength', 'LoRA 强度')); strength.onchange = () => { if (strength.reportValidity() && strength.value !== '') { row.strength = Number(strength.value); write(rows); } }; const trigger = el('input', null, 'fv-lora-trigger'); trigger.type = 'text'; trigger.maxLength = 300; @@ -236,6 +247,7 @@ export function loraPanel(node, panel, {promptNode = null} = {}) { list.append(wrap); loadTriggers(row.name).catch(error => { if (!disposed && trigger.isConnected) note.textContent = error.message; }); }); + if (rows.length > 1) actions.append(clear); note.textContent = rows.map(row => errors.get(row.name)).find(Boolean) || loraWarning(); } catch (error) { if (error instanceof DOMException) throw error; @@ -248,12 +260,14 @@ export function loraPanel(node, panel, {promptNode = null} = {}) { } const add = button(t('Add', '添加'), async () => { await refresh(); // The folder may have changed since the list was last read. - try { const rows = read(); if (rows.length >= 32) throw new Error(t('Use at most 32 LoRAs', '最多使用 32 个 LoRA')); if (!names.length) throw new Error(t('Put LoRA files (.safetensors) in the models/loras folder, then choose "Add".', '请把 LoRA 文件(.safetensors)放进 models/loras 文件夹,再点“添加”。')); rows.push({name: names[0], strength: 1, enabled: true}); write(rows); } catch (error) { note.textContent = error.message; } + try { const rows = read(); if (rows.length >= 32) throw new Error(t('Use at most 32 LoRAs', '最多使用 32 个 LoRA')); if (!names.length) throw new Error(t('Put LoRA files (.safetensors) in the models/loras folder, then choose "Add".', '请把 LoRA 文件(.safetensors)放进 models/loras 文件夹,再点“添加”。')); const next = recentLoras().find(n => names.includes(n) && !rows.some(row => row.name === n)) ?? names[0]; rememberLora(next); rows.push({name: next, strength: 1, enabled: true}); write(rows); } catch (error) { note.textContent = error.message; } }); async function refresh() { try { const response = await api.fetchApi('/models/loras', {cache: 'no-store'}); if (!response.ok) throw new Error(response.statusText); const listed = await response.json(); names = (Array.isArray(listed) ? listed : []).filter(n => typeof n === 'string' && n.toLowerCase().endsWith('.safetensors')); render(); } catch (error) { if (!disposed) note.textContent = error.message; } } + // Only deselects: the files stay in the folder and the recent names are kept. + const clear = button(t('Clear', '清空'), () => { write([]); }); actions.append(add); const changed = e => { if (String(e.detail) === String(node.id)) render(); }; window.addEventListener('freevideo-loras', changed); From 211308debca6d64521990265720aa91d082c383c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:35:38 +0000 Subject: [PATCH 25/53] Reminder when a long run finishes in a background tab --- scripts/test_freevideo_entry.mjs | 5 ++- web/done_notify.js | 77 ++++++++++++++++++++++++++++++++ web/freevideo.js | 7 ++- web/health.js | 2 +- 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 web/done_notify.js diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 05749a41..53fe1439 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -26,7 +26,7 @@ class Element { test('main browser entry registers both views and preserves node hooks', async t => { const extensions = [], opened = [], installed = [], events = new EventTarget(); - let refreshed = 0, compatibilityChecks = 0, updateChecks = 0, rememberedView = null; + let refreshed = 0, compatibilityChecks = 0, updateChecks = 0, doneNotifyStarts = 0, rememberedView = null; let progressView, progressOptions, realProgressFactory; const app = { registerExtension(extension) { extensions.push(extension); }, @@ -62,6 +62,7 @@ test('main browser entry registers both views and preserves node hooks', async t createProgressConnection: () => ({start() {}, refresh() {}, reset() {}}), notifyCompatibility: async () => { compatibilityChecks++; }, startUpdateChecks: () => { updateChecks++; }, + startDoneNotify: () => { doneNotifyStarts++; }, askDesktopPermission: () => {}, installNavigation: callback => installed.push(callback), refreshNavigation: () => refreshed++, preferredView: () => rememberedView, attachReferencePicker() {}, referenceItems: () => [], syncReferencePrompt() {}, @@ -90,6 +91,7 @@ test('main browser entry registers both views and preserves node hooks', async t .replace("import { createProgressConnection } from './progress_connection.js';", 'const {createProgressConnection} = globalThis.__freevideoEntryTest;') .replace("import { notifyCompatibility } from './compatibility.js';", 'const {notifyCompatibility} = globalThis.__freevideoEntryTest;') .replace("import { startUpdateChecks } from './updates.js';", 'const {startUpdateChecks} = globalThis.__freevideoEntryTest;') + .replace("import { startDoneNotify, askDesktopPermission, DESKTOP_SETTING } from './done_notify.js';", 'const {startDoneNotify,askDesktopPermission} = globalThis.__freevideoEntryTest; const DESKTOP_SETTING = "FreeVideo.DoneNotify.Desktop";') .replace("import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs } from './prompt_references.js';", 'const {attachReferencePicker,referenceItems,syncReferencePrompt,referenceInputs} = globalThis.__freevideoEntryTest;') .replace("import { outputDownloadURL } from './output_download.js';", 'const {outputDownloadURL} = globalThis.__freevideoEntryTest;') .replace("import { shareButton } from './share.js';", 'const {shareButton} = globalThis.__freevideoEntryTest;') @@ -110,6 +112,7 @@ test('main browser entry registers both views and preserves node hooks', async t assert.equal(installed.length, 1, 'the Create / Nodes navigation must be installed'); assert.equal(compatibilityChecks, 1); assert.equal(updateChecks, 1); + assert.equal(doneNotifyStarts, 1); class GenerateNode { constructor() { diff --git a/web/done_notify.js b/web/done_notify.js new file mode 100644 index 00000000..c2bcec37 --- /dev/null +++ b/web/done_notify.js @@ -0,0 +1,77 @@ +// Tells the user a long run has finished while FreeVideo is in a background tab: +// the tab title flashes and one short beep plays until the page is shown again. +// A desktop notification is sent only when the user turned it on in the settings. +export const DESKTOP_SETTING = 'FreeVideo.DoneNotify.Desktop'; +const LONG_RUN_MS = 20000, FLASH_MS = 1000; + +export function startDoneNotify({api, text, doc = document, win = window, now = () => Date.now(), desktop = () => false, + beep = playBeep, notify = desktopNotice, minimum = LONG_RUN_MS, setTimer = setInterval, clearTimer = clearInterval} = {}) { + let busySince = null, failed = false, cancelled = false, flashing = null; + const away = () => doc.hidden || (typeof doc.hasFocus === 'function' && !doc.hasFocus()); + + function stopFlashing() { + if (!flashing) return; + clearTimer(flashing.timer); doc.title = flashing.title; flashing = null; + } + function remind(ok) { + const label = ok ? text('✓ Done', '✓ 已完成') : text('✕ Stopped', '✕ 已停止'); + stopFlashing(); + const title = doc.title; let lit = false; + const tick = () => { lit = !lit; doc.title = lit ? label : title; }; + flashing = {title, timer: setTimer(tick, FLASH_MS)}; tick(); + try { beep(win); } catch { /* A browser may block sound before the first click. */ } + try { if (desktop()) notify(win, label, () => { win.focus?.(); stopFlashing(); }); } catch { /* Notifications are optional. */ } + } + function status(event) { + const remaining = event?.detail?.exec_info?.queue_remaining; + if (!Number.isFinite(remaining)) return; + if (remaining > 0) { + if (busySince === null) { busySince = now(); failed = false; cancelled = false; } + return; + } + if (busySince === null) return; + const elapsed = now() - busySince; busySince = null; + if (cancelled || elapsed < minimum || !away()) return; + remind(!failed); + } + const error = () => { failed = true; }; + const interrupted = () => { cancelled = true; }; + const shown = () => { if (!away()) stopFlashing(); }; + api.addEventListener('status', status); + api.addEventListener('execution_error', error); + api.addEventListener('execution_interrupted', interrupted); + doc.addEventListener('visibilitychange', shown); + win.addEventListener?.('focus', shown); + return {stop() { + api.removeEventListener('status', status); api.removeEventListener('execution_error', error); + api.removeEventListener('execution_interrupted', interrupted); + doc.removeEventListener('visibilitychange', shown); win.removeEventListener?.('focus', shown); stopFlashing(); + }}; +} + +function playBeep(win) { + const Context = win.AudioContext || win.webkitAudioContext; + if (!Context) return; + const context = new Context(); + if (context.state === 'suspended') context.resume?.(); + const tone = context.createOscillator(), gain = context.createGain(), start = context.currentTime; + tone.type = 'sine'; tone.frequency.value = 880; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.2, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.35); + tone.connect(gain); gain.connect(context.destination); + tone.start(start); tone.stop(start + 0.4); + tone.onended = () => context.close?.(); +} + +function desktopNotice(win, label, clicked) { + const Notice = win.Notification; + if (!Notice || Notice.permission !== 'granted') return; + const notice = new Notice(`${label} · FreeVideo`); + notice.onclick = () => { clicked(); notice.close?.(); }; +} + +// Asks for notification permission when the user switches the setting on. +export function askDesktopPermission(win = window) { + try { win.Notification?.requestPermission?.(); } catch { /* Optional. */ } +} diff --git a/web/freevideo.js b/web/freevideo.js index 95a02ab8..948d4270 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -7,6 +7,7 @@ import { createGenerationProgress, referenceTrimText, thermalText, sharedGpuText import { createProgressConnection } from './progress_connection.js'; import { notifyCompatibility } from './compatibility.js'; import { startUpdateChecks } from './updates.js'; +import { startDoneNotify, askDesktopPermission, DESKTOP_SETTING } from './done_notify.js'; import { installNavigation, refreshNavigation, preferredView } from './view_navigation.js'; import { agentInterfaceEnabled } from './agent_connect.js'; import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs } from './prompt_references.js'; @@ -619,13 +620,17 @@ const progressConnection = createProgressConnection(api, message => { window.dispatchEvent(new CustomEvent('freevideo-progress', {detail: message})); }); +const desktopNoticeEnabled = () => (app.extensionManager?.setting?.get?.(DESKTOP_SETTING) ?? app.ui?.settings?.getSettingValue?.(DESKTOP_SETTING)) === true; + app.registerExtension({ name: "FreeVideo.UnifiedMedia", + settings: [{id: DESKTOP_SETTING, name: text('Desktop notification when done', '完成时发桌面通知'), + type: 'boolean', defaultValue: false, onChange: value => { if (value) askDesktopPermission(); }}], beforeConfigureGraph() { configuringGraph = true; configuration++; }, async setup() { // One failing part must not take the others down; health.js shows what failed. for (const [where, start] of [['progress', () => progressConnection.start()], ['navigation', () => installNavigation(showStudio, agentInterfaceEnabled(api))], - ['updates', startUpdateChecks]]) { + ['updates', startUpdateChecks], ['done-notify', () => startDoneNotify({api, text, desktop: desktopNoticeEnabled})]]) { try { start(); } catch (error) { health({type: 'error', where, error}); } } window.addEventListener('pagehide', () => savePromptDraft(api, true)); diff --git a/web/health.js b/web/health.js index 8e04ae56..f65766ce 100644 --- a/web/health.js +++ b/web/health.js @@ -15,7 +15,7 @@ const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', - 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', + 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'report_issue.js', 'studio_recent.js', 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js']; From 2fb0055bf71f05cd53d4b87b970439f84809f261 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:38:45 +0000 Subject: [PATCH 26/53] Windows int8 second pass sizes refinement heads from live allocator limits --- freevideo_engine/policy.py | 70 +++++++++++++++++++++++++++++++++++++- freevideo_engine/worker.py | 19 +++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 13e87aec..8636924d 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -404,6 +404,62 @@ def interpolate(group): INT8_GPU_OUTPUT_MARGIN_WINDOWS = int(.5 * GiB) +# Report 243, B200 on pinned OpenVDN/Diffusers, int8, cudnn/torch-flash, +# native allocator, 1088x1920x362 + 2040 reference rows (220320 effective +# tokens), staged residual, grouped host outputs, stream, no resident block, +# window 4, FF 2048 / projection 1024. One cold and two warm refinement steps +# at the same 10 GiB cap: head 4/2 allocated peaks 7.309/6.797 GiB; the largest +# paired-step widening cost was 601331200 bytes (0.56003 GiB, the cold step). +# That is 2729.35 bytes per effective token, scaled below without rounding down. +# Reserved peaks were 8.342/8.736 GiB: pool layout is not live activation cost. +# At a shared 14 GiB cap, allocated was 7.313/6.797 (delta 0.516 GiB), but +# reserved was 10.299/8.693 (delta 1.605 GiB). The 0.56003 paired-step bound +# covers both caps; the reserved delta must not become an activation slope. +# See benchmarks/report243-b200. B200 absolute peaks are NOT Windows/sage2 +# peaks. Charge only its measured widening delta over the existing Windows +# staged envelope, with the same 0.5 GiB allocator margin used above. Report +# 243's allocator fell from its 10.69 GiB plan to 10.00 GiB before refinement; +# a plan's budget is only an upper bound on that live allocator ceiling. +# Do not extrapolate this correction below its measured token count: smaller +# Windows staging requests already have completed head-4 capacity evidence. +WINDOWS_INT8_CPU_HEAD_TOKENS = 220320 +WINDOWS_INT8_CPU_HEAD4_EXTRA = 601331200 + + +def windows_int8_cpu_output_need(tokens, canvas=None): + """Head-4 staged Windows envelope including measured widening and margin. + + A starting estimate for this large-token route, not B200/Windows capacity + equivalence. None preserves the smaller, independently qualified paths. + """ + if tokens is None or tokens < WINDOWS_INT8_CPU_HEAD_TOKENS: + return None + adjustment = int(4.6 * GiB * (tokens / (72 * 1008) - 1.)) + staged = max(decoder_workspace(canvas), + RESIDUAL_ACTIVATION_RESERVE + adjustment - tokens * FF_STASH_WIDTH * 2) + extra = (WINDOWS_INT8_CPU_HEAD4_EXTRA * tokens + WINDOWS_INT8_CPU_HEAD_TOKENS - 1) // WINDOWS_INT8_CPU_HEAD_TOKENS + return staged + extra + INT8_GPU_OUTPUT_MARGIN_WINDOWS + + +def windows_int8_refinement_head_need(engine, canvas, *, windows=False, lora=False): + """The measured envelope only for the eligible Windows refinement route.""" + if (not windows or lora or engine.get('linear_compute') != 'int8' or engine.get('head_chunk') != 4 + or not engine.get('residual_offload') or not engine.get('attention_cpu_outputs') + or not engine.get('grouped_attention_outputs') or not engine.get('stream_weights') + or engine.get('resident_blocks') != 0): + return None + from .two_pass import placement_tokens + return windows_int8_cpu_output_need(placement_tokens(canvas), canvas) + + +def windows_int8_refinement_heads(engine, canvas, allocator_limit, *, windows=False, lora=False): + """Pass-local narrowing when Windows's allocator shrank since planning.""" + if type(allocator_limit) is not int or allocator_limit <= 0: + return None + need = windows_int8_refinement_head_need(engine, canvas, windows=windows, lora=lora) + return {'head_chunk': 2} if need is not None and need > allocator_limit else None + + def int8_gpu_output_need(tokens, windows=False, full_tail=False): """Bytes the int8 staging path needs with its attention outputs on the GPU. @@ -1387,6 +1443,15 @@ def held(group): # at all -- it is the bounded retained subset that stops those bytes # being reread every step, which is the larger cost there. pin = 0. + windows_cpu_head_limited = False + if (desktop and int8 and not lora and stage == 'generation' and not capacity_trial + and head == 4 and residual_offload and cpu_outputs and streamed_weights and resident == 0): + head4_need = windows_int8_cpu_output_need(effective_tokens, canvas) + if head4_need is not None and head4_need > gpu_budget: + # Fund activations before dispatch. Keep residency, windows, host + # caching and every other compute/placement choice intact. + head = 2 + windows_cpu_head_limited = True engine = dict(attention='/'.join(legs), prefetch=prefetch, adaln_cache=True, adaln_disk_cache=True, inference_kernels=True, # One chunk size for every band, on neutrality rather than @@ -1456,10 +1521,13 @@ def held(group): 'This is a capacity attempt, not measured proof that this GPU fits.' % (gpu_budget / GiB, residual_floor / GiB)) if windows_activation_limited: - notes.append('The Windows GPU-output activation estimate exceeds the live budget even with zero resident blocks. Start this token count with four-head groups, %s attention outputs and one host residual buffer; estimated workspace %.2f GiB. Resolution, duration, steps, weights and attention backend are preserved. This is request-specific, not a persistent compatibility downgrade.' % ('host' if cpu_outputs else 'GPU', windows_workspace / GiB)) + notes.append('The Windows GPU-output activation estimate exceeds the live budget even with zero resident blocks. Start this token count with %s-head groups, %s attention outputs and one host residual buffer; estimated workspace %.2f GiB. Resolution, duration, steps, weights and attention backend are preserved. This is request-specific, not a persistent compatibility downgrade.' % ('two' if head == 2 else 'four', 'host' if cpu_outputs else 'GPU', windows_workspace / GiB)) elif windows_workspace is not None: notes.append('Windows head-8 GPU-output workspace estimate: %.2f GiB for the selected FF storage path, caches and transfer buffers; remaining live budget permits %d resident blocks. Based on complete Windows measurements, not a capacity guarantee. Head/window/FF tile shapes and system reserves are unchanged.' % (windows_workspace / GiB, resident)) + if windows_cpu_head_limited: + notes.append('The large-token Windows int8 host-output/residual path estimates %.2f GiB for four heads, including measured widening workspace and allocator margin, above the %.2f GiB planning upper bound on the live allocator. Start with two heads; resident blocks, window batch, weight caching, geometry, steps and backend are preserved. B200 measured the head-width delta, not this Windows GPU\'s capacity.' % + (head4_need / GiB, gpu_budget / GiB)) if residual_offload: notes.append('The original small path exceeds the live VRAM budget: stage residuals in one reusable CPU buffer, preserving kernel shapes, dtype and every requested row. Host weight caching leaves an additional %.2f GiB for this buffer. Extra transfers cost time; this is a capacity route, not a claimed speedup.' % (residual_host / GiB)) if streamed_weights: diff --git a/freevideo_engine/worker.py b/freevideo_engine/worker.py index e50e4310..650ba9b0 100644 --- a/freevideo_engine/worker.py +++ b/freevideo_engine/worker.py @@ -532,10 +532,29 @@ def refined_complete(video, sound, receipt): if continuation is not None: from .continuation import load final_context = load(continuation, 'final') + # The first pass/upscaler can leave a lower WDDM cap + # than the one that selected the refinement's heads. + # Recheck before its first allocation, not after OOM. + from .policy import windows_int8_refinement_heads, windows_int8_refinement_head_need + guard = dict(windows=metrics['device_memory'].get('windows_allocator_limit_enforced', False) + and not request.get('capacity_trial', False), + lora=bool(manifest.get('online_lora'))) + head_need = windows_int8_refinement_head_need(engine.config, canvas, **guard) + second_compute = None + if head_need is not None: + if live_budget is not None: + refresh_budget('refine') + limit = metrics['device_memory'].get('effective_allocator_limit_bytes') + second_compute = windows_int8_refinement_heads(engine.config, canvas, limit, **guard) + if second_compute: + metrics['refinement_head_planning'] = dict(planned_head_chunk=4, head_chunk=2, + allocator_limit_bytes=limit, head4_need_bytes=head_need) + save(request['metrics'], metrics) latents, audio, second = engine.sample(request['conditioning'], restart_seed, **({'continuation': final_context} if final_context is not None else {}), **sampling_plan['second'], initial_latents=(latents, audio), refine_steps=sampling_plan['refine_steps'], refine_schedule=sampling_plan.get('refine_schedule'), + compute_options=second_compute, final_step_callback=None if decoder_cached else read_decoder_ahead, step_callback=memory_diagnostics.complete, sampling_complete_callback=refined_complete, progress_offset=sampling_plan['base_steps'], progress_total=sampling_plan['total_steps'], From c40cbde61e51f9a256424b9430885888bdce60fd Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:38:53 +0000 Subject: [PATCH 27/53] Text encoder reads ahead in a bounded window when full prefetch is skipped --- freevideo_engine/encode_worker.py | 34 ++++ freevideo_engine/encoder_window.py | 273 +++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 freevideo_engine/encoder_window.py diff --git a/freevideo_engine/encode_worker.py b/freevideo_engine/encode_worker.py index d5882626..7df26d07 100644 --- a/freevideo_engine/encode_worker.py +++ b/freevideo_engine/encode_worker.py @@ -187,6 +187,36 @@ def stop_read_ahead(trace): trace.data['encoder_read_ahead'] = reader.close() +def start_layer_window(trace, clip, path): + """Read the streamed layers ahead of the forward pass when the whole-file read-ahead could not. + + Speculative like the readers above: any failure leaves the encode exactly as before. Once per request.""" + if getattr(trace, 'layer_window', None) is not None or 'encoder_window' in trace.data: + return + if os.environ.get('FREEVIDEO_ENCODER_WINDOW', 'auto').lower() in ('off', '0', 'false'): + trace.data['encoder_window'] = dict(state='skipped', reason='Disabled by FREEVIDEO_ENCODER_WINDOW') + return + if (trace.data.get('encoder_read_ahead') or {}).get('state') == 'ready': + trace.data['encoder_window'] = dict(state='skipped', reason='The whole checkpoint is already in the OS cache') + return + try: + from .encoder_checkpoint import owns_readonly_tensor + from .encoder_window import LayerWindow, layer_modules + modules = layer_modules(clip.cond_stage_model, owns_readonly_tensor) + release = os.environ.get('FREEVIDEO_ENCODER_WINDOW_RELEASE', 'auto').lower() not in ('off', '0', 'false') + trace.layer_window = window = LayerWindow(path, modules, release=release) + window.start() + except Exception as error: + trace.data['encoder_window'] = dict(state='failed', reason=type(error).__name__ + ': ' + str(error)) + + +def stop_layer_window(trace): + """Stop the layer window, once, and keep what it did in the metrics.""" + window = getattr(trace, 'layer_window', None) + if window is not None and 'encoder_window' not in trace.data: + trace.data['encoder_window'] = window.close() + + def start_model_read_ahead(trace, request): """On a slow drive, read the video model's files while the encoder computes. @@ -221,6 +251,7 @@ def encode(args, request, resident=None, diagnostics=None): metrics = _encode(args, request, resident, trace) except BaseException: stop_read_ahead(trace) + stop_layer_window(trace) stop_model_read_ahead(trace) trace.finish(False) raise @@ -485,6 +516,8 @@ def timed_load(*values, **kwargs): phase('encoder_compute', load_seconds=load_seconds, device_load_seconds=transfer_seconds[0], gpu=snapshot(torch, clip, memory)) print(json.dumps({'event': 'encoder_compute_start'}), flush=True) + if not encoder_cache_hit: + start_layer_window(trace, clip, path) return result @contextmanager def attempt(index, failure): @@ -550,6 +583,7 @@ def attempt(index, failure): phase('encoder_conditioning_pack', gpu=snapshot(torch, clip, memory)) finally: del clip.load_model + stop_layer_window(trace) guard.disarm() if reader is not None: reader.close() diff --git a/freevideo_engine/encoder_window.py b/freevideo_engine/encoder_window.py new file mode 100644 index 00000000..c0345c2f --- /dev/null +++ b/freevideo_engine/encoder_window.py @@ -0,0 +1,273 @@ +"""Read the encoder's streamed layers a few layers ahead of the forward pass. + +When live RAM cannot hold the 15.7 GB checkpoint, neither whole-file read-ahead +runs and every layer the device does not keep is copied from the read-only +mapping through page faults, one small read at a time. A 16 GB Windows RTX 4060 +Ti spent 127.6 s computing a prompt that takes 1-3 s on the device, at 0.11 GB/s, +while the same drive read the video model's weights at 0.24-0.72 GB/s in large +blocks a minute later. + +The forward pass visits the layers in order and touches each one once, so the +next layers' bytes are known. This reader keeps a small number of layers in +front of the pass: it reads their byte ranges in large blocks (the faults then +find the pages in the OS cache) and gives the pages of finished layers back, so +the encoder never holds more than a few hundred MB beyond what it is using. +It keeps no private copy, CUDA allocation or pinned memory. +""" +import ctypes +import json +import mmap +import os +import re +import struct +import threading +import time +from pathlib import Path + +from .encoder_prewarm import CHUNK +from .hardware import cgroup_memory +from .system import system_memory + +LEAD_LAYERS = 3 +# The window holds a few layers and gives finished ones back, so it needs no +# room beyond what the pass would take for itself; only a nearly empty machine +# (live RAM below this) pauses it. +PRESSURE_FLOOR_BYTES = 32 * 2**20 +# How long one layer may wait for room before the reader stands down for good. +STALL_LIMIT_SECONDS = 20. +LAYER_NAME = re.compile(r'(?:^|\.)layers\.(\d+)\.') +EMBED_NAME = re.compile(r'(?:^|\.)embed_tokens\.') + + +def available_memory(): + """Live RAM the window may count on: clean file cache is returned on demand, so the cgroup's + reclaimable headroom is used, not the raw remainder under the cap (which cache always fills).""" + available = system_memory()['available_bytes'] + group = cgroup_memory() + reclaimable = group.get('reclaimable_available_bytes') + if reclaimable is not None: + available = min(available, reclaimable) + return available, group.get('complete', True) + + +def layer_ranges(path): + """File byte ranges of the language model's embedding (key -1) and each layer, merged and sorted.""" + with open(path, 'rb') as stream: + size = os.fstat(stream.fileno()).st_size + prefix = stream.read(8) + if len(prefix) != 8: + raise ValueError('Incomplete safetensors header') + header_size = struct.unpack(' begin: + found.setdefault(key, []).append((base + begin, base + end)) + merged = {} + for key, spans in found.items(): + spans.sort() + out = [list(spans[0])] + for begin, end in spans[1:]: + if begin <= out[-1][1]: + out[-1][1] = max(out[-1][1], end) + else: + out.append([begin, end]) + merged[key] = [tuple(span) for span in out] + return merged + + +def _mapping_base(path): + """Address of the checkpoint mapping's first byte, from the loader's own record, or None.""" + from .encoder_checkpoint import MAPPINGS, READ_ONLY_RANGES + live = [reference() for reference in MAPPINGS.get(os.path.realpath(path), [])] + live = [mapping for mapping in live if mapping is not None and not mapping.closed] + if len(live) != 1: + return None, None + for reference, begin, end in READ_ONLY_RANGES: + if reference() is live[0]: + return live[0], begin + return live[0], None + + +class LayerWindow: + def __init__(self, path, modules, *, lead=LEAD_LAYERS, release=True): + """`modules` maps a layer key (-1 embedding, else layer index) to the module whose forward touches it.""" + self.path = Path(path) + self.modules = modules + self.lead = max(1, int(lead)) + self.release = release + self.cond = threading.Condition() + self.cancelled = False + self.thread = None + self.handles = [] + self.plan = [] # [(key, ranges, bytes)] in execution order + self.reached = -1 # plan position the pass has entered + self.next = 0 # plan position the reader will read next + self.released = 0 # plan positions already given back, this sweep + self.closed = None + self.mapping = None + self.address = None + self.result = dict(state='not-started', layers=0, read_bytes=0, released_bytes=0, stalls=0, + sweeps=0, elapsed_seconds=0., lead_layers=self.lead, + scope='Streamed encoder layers; reclaimable OS cache, no GPU residency') + + def start(self): + if self.thread is not None: + return self + ranges = layer_ranges(self.path) + keys = [key for key in sorted(ranges) if key in self.modules] + self.plan = [(key, ranges[key], sum(end - begin for begin, end in ranges[key])) for key in keys] + if not self.plan: + self.result.update(state='skipped', reason='No streamed layers') + return self + self.stamp = self.path.stat() + self.mapping, self.address = _mapping_base(self.path) + position = {key: index for index, (key, _, _) in enumerate(self.plan)} + for key, module in self.modules.items(): + if key in position: + self.handles.append(module.register_forward_pre_hook(self._entered(position[key]))) + self.result.update(state='reading', layers=len(self.plan), + planned_bytes=sum(size for _, _, size in self.plan), + reason='The forward pass is read a few layers ahead of its page faults') + self.thread = threading.Thread(target=self._run, name='FreeVideo encoder window', daemon=True) + self.thread.start() + return self + + def _entered(self, position): + def hook(module, args, kwargs=None): + with self.cond: + if position <= self.reached: + # The pass started again (a retry): read from here once more. + self.result['sweeps'] += 1 + self.next = position + self.released = position + self.reached = position + self.cond.notify_all() + return hook + + def _run(self): + started = time.monotonic() + scratch = bytearray(CHUNK) + try: + with self.path.open('rb', buffering=0) as file: + while True: + with self.cond: + while not self.cancelled and (self.next >= len(self.plan) + or self.next > self.reached + self.lead): + self.cond.wait(0.1) + self._give_back() + if self.cancelled: + self.result['state'] = 'stopped' + self.result['reason'] = 'The encoder finished' + return + position = self.next + key, spans, size = self.plan[position] + if not self._room(size): + self.result.update(state='stopped', reason='Live RAM stayed too low to read ahead') + return + for begin, end in spans: + file.seek(begin) + remaining = end - begin + while remaining: + if self.cancelled: + break + count = file.readinto(memoryview(scratch)[:min(CHUNK, remaining)]) + if not count: + raise ValueError('Encoder checkpoint truncated during read window') + remaining -= count + self.result['read_bytes'] += count + with self.cond: + if self.next == position: + self.next = position + 1 + self._give_back() + except Exception as error: + # Speculative I/O never replaces the loader's own reads and validation. + self.result.update(state='failed', reason=type(error).__name__ + ': ' + str(error)) + finally: + self.result['elapsed_seconds'] = time.monotonic() - started + + def _room(self, size): + waited = time.monotonic() + while not self.cancelled: + available, complete = available_memory() + if not complete or available >= PRESSURE_FLOOR_BYTES: + return True + self.result['stalls'] += 1 + if time.monotonic() - waited > STALL_LIMIT_SECONDS: + return False + time.sleep(0.02) + return True + + def _give_back(self): + """Release the pages of layers the pass has finished with (called holding the lock).""" + if not self.release: + return + while self.released < self.reached - 1 and self.released < len(self.plan): + for begin, end in self.plan[self.released][1]: + self.result['released_bytes'] += release_range(self.mapping, self.address, begin, end) + self.released += 1 + + def close(self): + """Stop reading and remove the hooks; return what was done. Later calls return the same result.""" + if self.closed is None: + with self.cond: + self.cancelled = True + self.cond.notify_all() + for handle in self.handles: + handle.remove() + self.handles.clear() + if self.thread is not None: + self.thread.join(timeout=2.) + if self.result['state'] == 'reading': + self.result['state'] = 'stopped' + self.closed = dict(self.result, reader_still_stopping=bool(self.thread and self.thread.is_alive())) + return self.closed + + +def release_range(mapping, address, begin, end): + """Give the pages of [begin, end) of the checkpoint back; clean pages of a read-only file refault on use. + + Linux/macOS advise the mapping away. Windows removes the pages from the + working set, which is where mapped pages stay until the system trims them. + Returns the bytes advised, 0 when nothing could be done.""" + page = mmap.PAGESIZE + first = -(-begin // page) * page + last = end // page * page + if last <= first: + return 0 + try: + if os.name == 'nt': + if address is None: + return 0 + ctypes.windll.kernel32.VirtualUnlock(ctypes.c_void_p(address + first), ctypes.c_size_t(last - first)) + return last - first + if mapping is None or not hasattr(mapping, 'madvise') or not hasattr(mmap, 'MADV_DONTNEED'): + return 0 + mapping.madvise(mmap.MADV_DONTNEED, first, last - first) + return last - first + except (OSError, ValueError, AttributeError): + return 0 + + +def layer_modules(model, owns_mapped): + """{key: module} for the language model's embedding and layers whose weights still sit in the mapping.""" + from .encoder_lowmem import language_model + llama = language_model(model) + found = {} + embed = getattr(llama, 'embed_tokens', None) + if embed is not None and any(owns_mapped(p) for p in embed.parameters()): + found[-1] = embed + for index, layer in enumerate(llama.layers): + if any(owns_mapped(p) for p in layer.parameters()): + found[index] = layer + return found From 07cb117368722ae7cff6cafd7fd410ca91d82c35 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:39:10 +0000 Subject: [PATCH 28/53] Setup page copy trimmed, hints left-aligned --- web/setup.css | 2 +- web/setup.js | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/web/setup.css b/web/setup.css index 2a4a71d0..06cf4bfe 100644 --- a/web/setup.css +++ b/web/setup.css @@ -61,7 +61,7 @@ .fv-setup a{color:var(--fv-accent);text-underline-offset:3px} .fv-setup .fv-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px} .fv-setup .fv-actions.fv-end{justify-content:flex-end} -.fv-setup .fv-setup-why{margin:8px 0 0;font-size:12.5px;line-height:1.5;text-align:center;text-wrap:balance} +.fv-setup .fv-setup-why{margin:8px 0 0;font-size:12.5px;line-height:1.5;text-align:left;text-wrap:balance} /* Text, plans and measurements. */ .fv-setup p{margin:8px 0} diff --git a/web/setup.js b/web/setup.js index d5c09a41..a0af7906 100644 --- a/web/setup.js +++ b/web/setup.js @@ -13,7 +13,7 @@ const languageOverride = typeof location !== 'undefined' const cn = languageOverride === 'zh' || (languageOverride !== 'en' && String(navigator.language || '').toLowerCase().startsWith('zh')); const t = (en, zh) => cn ? zh : en; -const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已装好;要重装请先点「检测并预览」'}; +const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已装好。要重装,请先点“检测并预览”。'}; const gib = n => Number.isFinite(n) ? (n / 2 ** 30).toFixed(1) + " GiB" : "—"; const failureText = value => { const text = String(value || ''), matches = [...text.matchAll(/MODEL_MISMATCH_REPAIR: ([^\r\n]+)/g)]; @@ -54,10 +54,9 @@ export async function openSetup() { const paths = node("pre"); libraries.append(paths); inputs.append(libraries); const copyLabel = node("label"); copyLabel.className = 'fv-check'; const copy = node("input"); copy.type = "checkbox"; 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 samplingText = node('span', t('Prepare all quality levels (optional)', '提前下载全部质量档位(可选)')); + const samplingText = node('span', t('Prepare all quality levels', '提前下载全部质量档位')); samplingLabel.append(samplingCaches, samplingText); inputs.append(samplingLabel); const downloads = node('section'); downloads.append(node('h3', t('Downloads', '下载'))); @@ -66,9 +65,8 @@ export async function openSetup() { for (const [id, name] of [['auto', t('Auto (recommended)', '自动(推荐)')], ['proxy', t('Proxy only', '仅代理')], ['direct', t('Direct only', '仅直连')]]) { const option = node('option', name); option.value = id; proxyMode.append(option); } - const modeHelp = note(''); modeLabel.append(proxyMode); - downloads.append(modeLabel, modeHelp); + downloads.append(modeLabel); const sourceLabel = node('label', t('Download source', '下载源')); const source = node('select'); for (const [id, name] of [['auto',t('Automatic','自动选择')],['official','Hugging Face'],['hf-mirror','HF Mirror'],['modelscope','ModelScope']]) { @@ -77,8 +75,7 @@ export async function openSetup() { sourceLabel.append(source); downloads.append(sourceLabel); const probeButton = node('button', t('Test source speeds', '重新测速')); const sourceStatus = note(''), speeds = node('div'); - downloads.append(probeButton, sourceStatus, speeds, - note(t('Switching applies to the current download; compatible fragments resume. Speed tests do not switch.', '切换立即作用于当前下载,兼容断点会续传。仅测速不切换。'))); + downloads.append(probeButton, sourceStatus, speeds); const auth = node('details'); auth.append(node('summary', t('Hugging Face token · optional', 'Hugging Face Token · 可选'))); auth.append(note(t('Use your account’s download quota instead of the anonymous limit. Kept for this session only.', '使用账号下载额度,避开匿名限流。仅本次会话有效。'))); @@ -145,7 +142,7 @@ export async function openSetup() { acceptLabel.hidden = true; installation.append(acceptLabel); const actions = node("div"); actions.className = "fv-actions"; const inspect = node("button", t("Detect & review", "检测并预览")); inspect.className = "fv-primary"; - const use = node("button", t("Use existing installation", "使用已有安装")); + const use = node("button", t("Use existing", "使用已有安装")); const install = node("button", t("Install / repair", "安装/修复")); install.disabled = true; const cancel = node("button", t("Stop setup", "停止安装")); cancel.disabled = true; const saveFolders = node('button', t('Save model folders', '保存模型目录')); @@ -223,7 +220,6 @@ export async function openSetup() { function showDownloads(value) { if (!value) return; proxyMode.value = value.preferences?.proxy_mode || 'auto'; - modeHelp.textContent = proxyMode.value === 'proxy' ? t('Use the current proxy. No direct fallback.', '沿用当前代理,不尝试直连。') : proxyMode.value === 'direct' ? t('Download directly, ignoring proxies.', '忽略代理,直接下载。') : t('Compare proxy and direct connections automatically.', '自动测速,择优使用代理或直连。'); authStatus.textContent = busy ? t('Stop setup to change the token, then resume with retained files.', '先停止安装,再修改 Token 并继续;已下载文件会保留。') : value.hf_token_set ? t('Session token set. Existing environment credentials apply when this field is cleared.', '已设置本次 Token;清除后仍可使用环境中已有的凭据。') : t('No session token set. Existing environment credentials still apply.', '未填写本次 Token;仍可使用环境中已有的凭据。'); @@ -273,11 +269,11 @@ export async function openSetup() { const controls = () => { for (const e of [root, extra, copy, samplingCaches, saveFolders, inspect, use, hfToken, applyToken, clearToken]) e.disabled = busy; cancel.disabled = !busy; install.disabled = busy || !planId || !accept.checked; accept.disabled = busy; inspect.classList.toggle("fv-primary", !planId); install.classList.toggle("fv-primary", Boolean(planId)); - const why = busy ? "" : planStale ? t("Settings changed. Click Detect & review again to update the plan.", "设置已更改,请重新点击「检测并预览」更新安装计划。") - : planErrors ? t("The plan has problems shown in red above. Please resolve them, then click Detect & review again.", "安装计划中有标红的问题,请解决后重新点击「检测并预览」。") + const why = busy ? "" : planStale ? t("Settings changed. Click Detect & review again to update the plan.", "设置已更改,请重新点击“检测并预览”更新安装计划。") + : planErrors ? t("The plan has problems shown in red above. Please resolve them, then click Detect & review again.", "安装计划中有标红的问题,请解决后重新点击“检测并预览”。") : ready && !planId ? t(INSTALLED_HINT.en, INSTALLED_HINT.zh) - : !planId ? t("Click Detect & review first. Install / repair becomes available once the plan is ready.", "请先点击「检测并预览」,生成安装计划后即可安装。") - : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,即可点击「安装/修复」。") : ""; + : !planId ? "" + : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,即可点击“安装/修复”。") : ""; blocked.textContent = why; blocked.hidden = !why; }; // A changed setting makes a shown plan outdated: clear it rather than leave it beside a disabled button. @@ -397,7 +393,7 @@ export async function openSetup() { extra.value = (info.discovery.extra_libraries || []).join("\n"); showDownloads(info.discovery.downloads); showResources(info.discovery.resources); - samplingText.textContent = t('Prepare all quality levels (optional)', '提前下载全部质量档位(可选)') + samplingText.textContent = t('Prepare all quality levels', '提前下载全部质量档位') + (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; From 0953c5a6d7c70ed4a5eaa689448455f59bc47b46 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:39:11 +0000 Subject: [PATCH 29/53] Shorter generation failure card and diagnostics copy --- web/error_panel.js | 18 ++++++++---------- web/studio.js | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/web/error_panel.js b/web/error_panel.js index ef625d68..c29e5acf 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -271,12 +271,12 @@ export function failureAdvice(value, t, known = knownError(value)) { } // A custom step count is not a quality level: no "档" / "quality level" for it. const title = custom - ? [`The sampling table for Custom (${known.base_steps} steps) is not downloaded yet`, `“自定义(${known.base_steps} 步)”的采样表还没有下载`] - : [`The ${levels[index][0]} quality level's sampling table is not downloaded yet`, `${keep(`“${levels[index][1]}”档`)}的采样表还没有下载`]; - const what = custom ? ['the sampling table for this step count', '这个步数的采样表'] : ["that level's sampling table", '这一档的采样表']; + ? [`Custom (${known.base_steps} steps) is not downloaded yet`, `“自定义(${known.base_steps} 步)”还没下载`] + : [`The ${levels[index][0]} quality level is not downloaded yet`, `${keep(`“${levels[index][1]}”档`)}还没下载`]; + const what = custom ? ['This step count', '这个步数'] : ['This level', '这一档']; return {...row('sampling-table', title, - [`This video first needs ${what[0]} (about ${size}), and it could not be downloaded this time. Check the network connection and generate again; FreeVideo downloads it automatically.${known.available.length ? ` You can also switch to a quality level that is already available: ${join(0)}.` : ''}`, - `这次生成需要先下载${what[1]}(约 ${size}),但这次没能下载。请确认网络连接后再生成一次,FreeVideo 会自动下载。${known.available.length ? `也可以改用已经可以使用的${join(1)}。` : ''}`], ['', '']), plain: true}; + [`${what[0]} needs a download (about ${size}) that did not finish this time. Generate again when online and it downloads automatically.${known.available.length ? ` You can also use ${join(0)} for now.` : ''}`, + `${what[1]}要先下载(约 ${size}),这次没能下载。联网后再生成一次,会自动下载。${known.available.length ? `也可以先用${join(1)}。` : ''}`], ['', '']), plain: true}; } // ComfyUI and worker tracebacks carry English exceptions. Translate these // actionable failures through the existing signature/advice path. @@ -419,8 +419,7 @@ export function createErrorPanel(t) { const location = document.createElement('p'), cause = document.createElement('p'); cause.className = 'fv-failure-cause'; location.className = 'fv-failure-location'; const privacy = document.createElement('p'); privacy.className = 'fv-failure-privacy'; - privacy.textContent = t('Known input text, paths and common credentials are removed. Export saves a local JSON file; review it before sharing.', - '已去除已知输入文本、路径和常见凭据。导出会保存为本地 JSON 文件,分享前可查看内容。'); + privacy.textContent = t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); const downloadStatus = document.createElement('p'); downloadStatus.setAttribute('role', 'status'); let currentReport = null; const disclosure = document.createElement('details'), summary = document.createElement('summary'); @@ -517,9 +516,8 @@ export function createErrorPanel(t) { } privacy.textContent = download.hidden ? t('Use Download report in the preview or node panel for the diagnostic report.', '完整诊断请使用预览区或节点面板中的“下载报告”。') - : t('Known input text, paths and common credentials are removed. Export saves a local error summary; review it before sharing.', - '已去除已知输入文本、路径和常见凭据。导出会保存本地错误摘要,分享前可查看内容。'); - copy.textContent = advice.kind === 'sampling-table' ? t('Copy full details', '复制完整详情') : t('Copy error', '复制错误'); + : t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); + copy.textContent = advice.kind === 'sampling-table' ? t('Copy details', '复制详情') : t('Copy error', '复制错误'); if (popup) showFailureDialog(report, t); return report; }, clear() { currentReport = null; details.value = ''; element.hidden = true; }}; diff --git a/web/studio.js b/web/studio.js index 1a8f2141..903819ce 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1300,7 +1300,7 @@ export function openStudio(node, {extendFrom = null} = {}) { busy = cancelling = false; updateRunButton(); hideProgress(); status.dataset.error = 'true'; node.freevideoFailureReport = failure.show(e.detail, true, {stage: 'execution', node, graph: app.graph, progress: node.freevideoProgress}); node.freevideoFailure = errorText(node.freevideoFailureReport); - status.textContent = t('Generation stopped. See the explanation below.', '生成已停止,原因和处理建议见下方。'); + status.textContent = t('Generation stopped.', '生成已停止。'); }); listen('execution_interrupted', e => { if (chainController.active && String(e.detail?.node_id) === String(node.id)) { chainController.refresh(); return; } From ac1f853cc7c509542818c23a706ae8431e57f779 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:39:12 +0000 Subject: [PATCH 30/53] Editor picture properties: aligned number boxes with units inside --- web/editor_adjust_picture.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/editor_adjust_picture.css b/web/editor_adjust_picture.css index 07ef526a..a8a5024c 100644 --- a/web/editor_adjust_picture.css +++ b/web/editor_adjust_picture.css @@ -10,12 +10,13 @@ @media(max-width:560px){ .fv-editor[data-adjust-open=true] .fv-ed-preview,.fv-editor[data-adjust-open=true] .fv-ed-transport{margin-right:220px} .fv-editor .fv-ed-adjust-host{width:220px} + .fv-ed-adjust-picture .fv-ed-adjust-row:has([data-axis]){flex-direction:column;align-items:stretch} } -.fv-ed-adjust-picture .fv-ed-adjust-number{display:flex;align-items:center;gap:3px} +.fv-ed-adjust-picture .fv-ed-adjust-number{position:relative;display:flex;align-items:center;gap:3px;flex:0 0 auto} .fv-ed-adjust-picture .fv-ed-adjust-number[data-axis]:before{content:attr(data-axis);font-size:11px;color:var(--fv-muted)} -.fv-ed-adjust-picture .fv-ed-adjust-number[data-unit]:after{content:attr(data-unit);font-size:11px;color:var(--fv-muted)} -.fv-ed-adjust-picture .fv-ed-adjust-number input{min-width:0;padding:4px} +.fv-ed-adjust-picture .fv-ed-adjust-number[data-unit]:after{content:attr(data-unit);position:absolute;right:8px;top:50%;transform:translateY(-50%);font-size:11px;color:var(--fv-muted);pointer-events:none} +.fv-editor .fv-ed-adjust-picture .fv-ed-adjust-number input{box-sizing:border-box;width:76px;min-width:0;padding:4px 22px 4px 8px} .fv-ed-picture-input-error{margin:0;color:var(--fv-warning);font-size:12px} .fv-ed-picture-input-error[hidden]{display:none} From 611c58138f21a2fa7fb46e1becb1fd898513bb59 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:39:13 +0000 Subject: [PATCH 31/53] Floating view navigation fits narrow windows --- web/view_navigation.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/view_navigation.css b/web/view_navigation.css index 30fd60fe..721f3285 100644 --- a/web/view_navigation.css +++ b/web/view_navigation.css @@ -23,7 +23,11 @@ html.fv-toolbar-dragging,html.fv-toolbar-dragging *{cursor:grabbing!important;us /* Six dots in the left padding, beside the first row, as on ComfyUI's own run bar. */ .fv-view-row{position:relative}.fv-view-grip{position:absolute;left:-14px;top:50%;translate:0 -50%;width:8px;height:12px;opacity:0;pointer-events:none;color:var(--fv-muted);transition:opacity .2s,color .2s;background:radial-gradient(circle,currentColor 1.1px,transparent 1.5px) 0 0/4px 4px} .fv-view-navigation:hover .fv-view-grip{opacity:.75}.fv-view-navigation.fv-dragging .fv-view-grip{opacity:1;color:var(--fv-ink)}@media(hover:none){.fv-view-grip{opacity:.5}} -@media(max-width:480px){.fv-view-navigation .fv-version-info{display:none}} +@media(max-width:480px){ + .fv-view-navigation .fv-version-info,.fv-view-navigation .fv-view-row>.fv-wordmark{display:none} + .fv-view-navigation .fv-view-switch button{font-size:12px;padding:7px 6px} + .fv-view-navigation .fv-view-switch,.fv-view-navigation .fv-view-settings{flex-shrink:0} +} /* Reserve the selected label's width so bold English text cannot move the switch. */ .fv-view-switch button,.fv-studio .fv-view-switch button{display:grid;gap:0} From fef837a164064788285fc1871d0860444ee2176f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:39:14 +0000 Subject: [PATCH 32/53] Reference strip fades at the scrolled-out edge --- web/editor.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/editor.css b/web/editor.css index ed5584c4..b537f534 100644 --- a/web/editor.css +++ b/web/editor.css @@ -88,6 +88,11 @@ .fv-ed-refs[hidden],.fv-ed-ref-menu[hidden]{display:none} .fv-ed-ref-chips{display:flex;align-items:center;gap:6px;max-width:min(240px,32vw);overflow-x:auto;scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent;padding:1px} .fv-ed-ref-chips:empty{display:none} +/* A strip that scrolls fades at the hidden end(s); one that fits is untouched. */ +@property --fv-ref-fade-start{syntax:'';inherits:false;initial-value:0px} +@property --fv-ref-fade-end{syntax:'';inherits:false;initial-value:0px} +@keyframes fv-ref-fade{from{--fv-ref-fade-start:0px;--fv-ref-fade-end:16px}1%,99%{--fv-ref-fade-start:16px;--fv-ref-fade-end:16px}to{--fv-ref-fade-start:16px;--fv-ref-fade-end:0px}} +@supports (animation-timeline:scroll()){.fv-ed-ref-chips{animation:fv-ref-fade linear both;animation-timeline:scroll(self inline);mask-image:linear-gradient(90deg,transparent,#000 var(--fv-ref-fade-start),#000 calc(100% - var(--fv-ref-fade-end)),transparent)}} .fv-ed-ref-add,.fv-ed-ref-chip{flex:none;width:30px;height:30px;padding:0;border:1px solid var(--fv-border);border-radius:8px;background:transparent;color:var(--fv-muted);font:16px/1 var(--fv-font);cursor:pointer;overflow:hidden;position:relative} .fv-ed-ref-chip{border-color:transparent;background:var(--fv-bg)} .fv-ed-ref-add:not(:disabled):not([aria-disabled=true]):hover,.fv-ed-ref-add[aria-expanded=true],.fv-ed-ref-chip:not(:disabled):not([aria-disabled=true]):hover{border-color:var(--fv-accent);color:var(--fv-ink)} From 3f26702fbe159f4c5f1a9f9496f578d0c9ca6e5c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:39:15 +0000 Subject: [PATCH 33/53] Launcher settings: per-platform Python path placeholder, no repair note when unavailable --- freevideo_engine/launcher/Main.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index bd57d3bb..aaf7bb68 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -1223,7 +1223,7 @@ ApplicationWindow { FText { text: t("Engine folder", "引擎目录"); font.pixelSize: theme.micro; color: theme.muted } RowLayout { Layout.fillWidth: true; spacing: 8; FField { Layout.fillWidth: true; value: s.form.engine; placeholderText: t("Automatic", "自动选择"); onEditingFinished: backend.edit("engine", text) } FButton { objectName: "engineBrowse"; text: t("Browse…", "浏览…"); enabled: s.can_manage; implicitHeight: theme.height + 4; onClicked: backend.browse("engine") } } FText { text: t("ComfyUI Python", "ComfyUI Python"); font.pixelSize: theme.micro; color: theme.muted; Layout.topMargin: 4 } - FField { Layout.fillWidth: true; value: s.form.python; placeholderText: t("Path to python.exe", "python.exe 的路径"); onEditingFinished: backend.edit("python", text) } + FField { Layout.fillWidth: true; value: s.form.python; placeholderText: Qt.platform.os === "windows" ? t("Path to python.exe", "python.exe 的路径") : t("Path to python", "python 的路径"); onEditingFinished: backend.edit("python", text) } } FGroup { Layout.fillWidth: true; title: t("Maintenance", "维护"); enabled: !s.portable @@ -1235,8 +1235,8 @@ ApplicationWindow { Layout.fillWidth: true; spacing: 2 FText { text: t("Repair FreeVideo", "修复 FreeVideo"); Layout.fillWidth: true; color: s.can_repair ? theme.text : theme.disabled } FText { - text: s.can_repair ? t("Checks this installation and reinstalls anything missing or changed. Models and videos are kept.", "检查这份安装,重新安装缺失或被改动的部分。模型和视频都会保留。") - : t("Available once FreeVideo is installed.", "安装完成后可用。") + visible: s.can_repair + text: t("Checks this installation and reinstalls anything missing or changed. Models and videos are kept.", "检查这份安装,重新安装缺失或被改动的部分。模型和视频都会保留。") font.pixelSize: theme.micro; color: theme.muted; Layout.fillWidth: true } } From 19bff43815eeb718abffd0b2dffd211249361169 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:42:54 +0000 Subject: [PATCH 34/53] Setup Stop button shown only while a check or installation runs --- web/setup.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/setup.js b/web/setup.js index a0af7906..e541cfc4 100644 --- a/web/setup.js +++ b/web/setup.js @@ -144,7 +144,7 @@ export async function openSetup() { const inspect = node("button", t("Detect & review", "检测并预览")); inspect.className = "fv-primary"; const use = node("button", t("Use existing", "使用已有安装")); const install = node("button", t("Install / repair", "安装/修复")); install.disabled = true; - const cancel = node("button", t("Stop setup", "停止安装")); cancel.disabled = true; + const cancel = node("button", t("Stop setup", "停止安装")); cancel.hidden = true; const saveFolders = node('button', t('Save model folders', '保存模型目录')); actions.append(saveFolders, inspect, use, install, cancel); installation.append(actions); // Why Install / repair is unavailable, so it never looks like a broken button. @@ -268,7 +268,7 @@ export async function openSetup() { }; const controls = () => { for (const e of [root, extra, copy, samplingCaches, saveFolders, inspect, use, hfToken, applyToken, clearToken]) e.disabled = busy; - cancel.disabled = !busy; install.disabled = busy || !planId || !accept.checked; accept.disabled = busy; inspect.classList.toggle("fv-primary", !planId); install.classList.toggle("fv-primary", Boolean(planId)); + cancel.hidden = !busy; install.disabled = busy || !planId || !accept.checked; accept.disabled = busy; inspect.classList.toggle("fv-primary", !planId); install.classList.toggle("fv-primary", Boolean(planId)); const why = busy ? "" : planStale ? t("Settings changed. Click Detect & review again to update the plan.", "设置已更改,请重新点击“检测并预览”更新安装计划。") : planErrors ? t("The plan has problems shown in red above. Please resolve them, then click Detect & review again.", "安装计划中有标红的问题,请解决后重新点击“检测并预览”。") : ready && !planId ? t(INSTALLED_HINT.en, INSTALLED_HINT.zh) From aab8820f390c7fa44c58e06bc2ee6f5989ec80f9 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:43:16 +0000 Subject: [PATCH 35/53] Past chats show a small cover from their first finished video --- freevideo_engine/agent/routes.py | 24 ++++++++++++++++++++++-- web/autopilot_chat.css | 3 +++ web/autopilot_chat.js | 21 +++++++++++++++++++-- web/autopilot_conversations.js | 3 +++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 26a3577e..e47d59bf 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -956,12 +956,32 @@ def write(): @guarded async def chat_list(request): def read(): + from urllib.parse import quote store = work_sessions() here = chat_session(store) rows = store.history(chat['client']) if here else [] - active = [r.get('session') for r in jobs.rows() if r['status'] in ACTIVE] + listed = {row['id'] for row in rows} + running, covers = {}, {} + # One job snapshot supplies both counts and the first completed video, without reading session logs or files. + for job in jobs.rows(): + sid = job.get('session') + if sid not in listed: + continue + if job['status'] in ACTIVE: + running[sid] = running.get(sid, 0) + 1 + if job['status'] != 'succeeded' or job['kind'] == 'image': + continue + work = (job.get('result') or {}).get('library_id') + if not isinstance(work, str) or not work: + continue + finished = job.get('finished_at') + order = (finished if finished is not None else job['created_at'], job['created_at'], job['id']) + if sid not in covers or order < covers[sid][0]: + covers[sid] = (order, work) return dict(agent=agent_state(), here=here, - conversations=[dict(row, running=active.count(row['id'])) for row in rows]) + conversations=[dict(row, running=running.get(row['id'], 0), + cover='/freevideo/library/thumbnail?id=' + quote(covers[row['id']][1], safe='') + if row['id'] in covers else None) for row in rows]) return reply(await asyncio.to_thread(read)) @routes.post('/freevideo/agent/chat/new') diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 5d49dffa..41d8974b 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -12,6 +12,9 @@ .fv-studio .fv-chat-conversations button:focus-visible{outline-offset:-2px} .fv-studio .fv-chat-conversation[aria-current]{background:var(--fv-raised);border-color:var(--fv-accent-dim)} .fv-chat-conversation span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* Fit the cover inside the existing row padding without changing the title's line height. */ +.fv-chat-cover{flex:none;width:48px;height:27px;margin-block:-4px;overflow:hidden;border-radius:var(--fv-chats-radius);background:color-mix(in srgb,var(--fv-ink) 7%,transparent)} +.fv-chat-cover img{display:block;width:100%;height:100%;object-fit:cover} /* The row button keeps its full frame; the inset action only takes space from the title. */ .fv-chat-conversation-row{position:relative;min-width:0;display:flex;align-items:center;border-radius:var(--fv-chats-radius)} .fv-chat-conversation-row:is(:hover,:focus-within,[data-menu-open=true]){background:var(--fv-raised)} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index afe125b1..6ad77516 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -284,12 +284,29 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (!node) { const container = el('div', 'fv-chat-conversation-row'); const button = press('fv-chat-conversation', null, () => chooseChat(row.id)), dot = el('i', 'fv-chat-running'), title = el('span'); - button.dataset.session = row.id; dot.setAttribute('aria-hidden', 'true'); button.append(dot, title); + // Every row keeps the cover frame so titles line up whether or not a picture loads. + const frame = el('i', 'fv-chat-cover'); + button.dataset.session = row.id; dot.setAttribute('aria-hidden', 'true'); frame.setAttribute('aria-hidden', 'true'); + button.append(dot, frame, title); const more = press('fv-chat-more', null, () => openMenu(row.id)); more.title = t('More', '更多'); more.setAttribute('aria-haspopup', 'menu'); more.setAttribute('aria-controls', menu.id); more.append(glyph('M5 12h.01M12 12h.01M19 12h.01', 3.5)); more.addEventListener('focusout', leaveMenu); container.append(button, more); - listNodes.set(row.id, node = {row: container, button, dot, title, more}); + listNodes.set(row.id, node = {row: container, button, dot, frame, title, more, cover: null, image: null}); + } + if (node.cover !== row.cover) { + node.image?.remove(); node.image = null; node.cover = row.cover; + delete node.row.dataset.coverError; + if (row.cover) { + const image = el('img'); + image.alt = ''; image.setAttribute('aria-hidden', 'true'); image.loading = 'lazy'; image.decoding = 'async'; + image.width = 48; image.height = 27; + image.onerror = () => { + if (node.image !== image) return; + image.hidden = true; node.row.dataset.coverError = 'true'; + }; + node.image = image; image.src = row.cover; node.frame.append(image); + } } node.row.dataset.temporary = String(!!row.temporary); node.title.textContent = row.title; node.button.title = row.title; node.dot.hidden = row.running <= 0; diff --git a/web/autopilot_conversations.js b/web/autopilot_conversations.js index 9974dd15..0b63efd9 100644 --- a/web/autopilot_conversations.js +++ b/web/autopilot_conversations.js @@ -27,7 +27,10 @@ export function mergeConversations(state, data, status = 200) { for (const row of data.conversations) { if (typeof row?.id !== 'string' || !row.id || typeof row.title !== 'string' || !row.title.trim() || seen.has(row.id)) continue; seen.add(row.id); + const cover = typeof row.cover === 'string' && row.cover.startsWith('/') && !row.cover.startsWith('//') + && !/[\\\s\u0000-\u001f\u007f]/.test(row.cover) ? row.cover : null; conversations.push({id: row.id, title: row.title, updated_at: row.updated_at, + cover, running: Math.max(0, Math.floor(Number(row.running) || 0))}); if (conversations.length === 50) break; } From 8e4554b3298a2b3306f872fbdce31e56b3dd13f5 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:58:05 +0000 Subject: [PATCH 36/53] Shorter interface copy; first view-switch tip removed --- scripts/test_prompt_guide.mjs | 2 +- web/autopilot_chat.js | 2 +- web/freevideo.js | 4 ++-- web/studio.js | 4 ++-- web/view_navigation.css | 6 +----- web/view_navigation.js | 28 ---------------------------- 6 files changed, 7 insertions(+), 39 deletions(-) diff --git a/scripts/test_prompt_guide.mjs b/scripts/test_prompt_guide.mjs index aa40a3fa..15eb7d51 100644 --- a/scripts/test_prompt_guide.mjs +++ b/scripts/test_prompt_guide.mjs @@ -21,7 +21,7 @@ for (const language of ['en', 'zh']) { assert.equal(link.rel, 'noopener noreferrer'); } const text = result.children.map(child => child.text).join(''); - assert.ok(text.includes(language === 'zh' ? '让AI参考' : 'ask AI to rewrite')); + assert.ok(text.includes(language === 'zh' ? '让 AI 参考 MiniMax H3 Prompt Skill ↗ 重写提示词' : 'ask AI to rewrite')); assert.ok(text.includes('FreeToken')); assert.equal(result.children.some(child => child.tag === 'button' || child.tag === 'input'), false); }); diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 6ad77516..519ad547 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -723,7 +723,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () }); const sendable = sendingAllowed(); input.disabled = state.agent.connected !== true || creating || !!selectedSession && selectedSession !== state.session; - input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to keep listening', '回到 Agent 那边让它继续') : t('Message the agent…', '对 Agent 说…'); + input.placeholder = state.agent.connected === true && !sendable ? t('Ask your agent to continue', '请在 Agent 里让它继续') : t('Message the agent…', '对 Agent 说…'); sendButton.disabled = !sendable || draftSending() || !input.value.trim(); if (focusSession === state.session && focusSession && !input.disabled && active()) { focusSession = null; input.focus(); } if (stick) { pinned = true; scroller.scrollTop = scroller.scrollHeight; down.hidden = true; } diff --git a/web/freevideo.js b/web/freevideo.js index 948d4270..2ed83532 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -291,8 +291,8 @@ function mediaPanel(node, mount = null) { const off = Math.abs(Math.log((preview.naturalWidth / preview.naturalHeight) / (size.width / size.height))) > .02; if (!off || !preview.isConnected) return; const note = el("div", undefined, "fv-shape-note"); - note.append(el("span", text(`Its shape differs from the video (${size.width}×${size.height}); the edges are cropped when generating.`, - `比例与视频(${size.width}×${size.height})不同,生成时会居中裁掉多出的部分。`)), + note.append(el("span", text('Different aspect ratio from the video; it will be center-cropped.', + '和视频比例不同,会居中裁剪。')), button(text("Adjust crop", "调整裁剪"), text("Choose what the video keeps", "选择视频保留的画面"), () => editImage(row.file, "crop"))); wrap.after(note); }, {once: true}); diff --git a/web/studio.js b/web/studio.js index 903819ce..4f512c8c 100644 --- a/web/studio.js +++ b/web/studio.js @@ -121,14 +121,14 @@ function saveTriggers(name, words) { export const loraWarning = () => t( 'FreeVideo needs no acceleration LoRA.', - 'FreeVideo无需添加加速LoRA'); + 'FreeVideo 无需添加加速 LoRA'); export function promptGuide() { const note = el('p', null, 'fv-prompt-guide'); const link = el('a', 'MiniMax H3 Prompt Skill ↗'); link.href = 'https://github.com/MiniMax-AI/MiniMax-H3/blob/main/skills/h3-prompt-writing/SKILL.md'; link.target = '_blank'; link.rel = 'noopener noreferrer'; - note.append(document.createTextNode(t('For better results, ask AI to rewrite your prompt using ', '让AI参考 ')), + note.append(document.createTextNode(t('For better results, ask AI to rewrite your prompt using ', '让 AI 参考 ')), link, document.createTextNode(t('. We recommend ', ' 重写提示词效果会更好。推荐使用 '))); const freeToken = el('a', 'FreeToken ↗'); freeToken.href = 'https://www.flashml.ai/'; diff --git a/web/view_navigation.css b/web/view_navigation.css index 721f3285..1507acca 100644 --- a/web/view_navigation.css +++ b/web/view_navigation.css @@ -5,11 +5,7 @@ .fv-view-switch button[aria-pressed=true],.fv-studio .fv-view-switch button[aria-pressed=true]{background:var(--fv-raised);color:var(--fv-ink);font-weight:var(--fv-semibold);box-shadow:0 1px 3px #0006}.fv-view-switch button:hover{color:var(--fv-ink)} .fv-view-switch button:focus-visible,.fv-view-navigation button:focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px} .fv-view-switch button[hidden],.fv-studio .fv-view-switch button[hidden]{display:none} -/* First-run tip: a tinted callout, not another line of grey text. */ -.fv-view-guide{display:flex;align-items:center;gap:10px;max-width:560px;margin-top:10px;padding:8px 8px 8px 12px;border-radius:var(--fv-r-sm);background:var(--fv-selected);font-size:12px;line-height:1.5;color:var(--fv-ink)} -.fv-view-guide::before{content:'i';flex:none;display:grid;place-items:center;width:18px;height:18px;border-radius:50%;background:var(--fv-accent);color:var(--fv-bg);font:600 11px/1 Georgia,serif} -.fv-view-guide>span{flex:1} -.fv-view-guide button{flex:none;color:var(--fv-accent);padding:4px 8px;font-weight:var(--fv-semibold)}.fv-view-guide button:hover{background:#ffffff0f}.fv-view-navigation .fv-view-settings{font-size:12px;padding:6px} +.fv-view-navigation .fv-view-settings{font-size:12px;padding:6px} .fv-studio .fv-header:has(.fv-view-switch){grid-template-columns:1fr auto 1fr}.fv-studio .fv-header>.fv-header-actions{justify-self:end;min-width:0}.fv-studio .fv-view-switch button{padding:5px 14px;min-height:var(--fv-h-sm)} @media(max-width:680px){.fv-view-navigation{top:68px;max-width:calc(100vw - 16px)}.fv-view-row{gap:6px}.fv-view-row>.fv-wordmark{width:90px}.fv-view-switch button{padding:7px 10px}.fv-studio .fv-header:has(.fv-view-switch){grid-template-columns:1fr auto 1fr;gap:8px}.fv-studio .fv-header:has(.fv-view-switch) .fv-wordmark{width:90px}.fv-studio .fv-view-switch{justify-self:center}.fv-studio .fv-view-switch button{padding:6px 9px}} diff --git a/web/view_navigation.js b/web/view_navigation.js index 1beee614..69b9dc36 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -120,34 +120,6 @@ export function installNavigation(openStudio, agent = false) { if (created) handle?.notify(t('Created an editing project for you.', '已为你新建一个剪辑工程')); return handle; }, true), () => choose(node => openAutopilot(node), true)), createVersionInfo(cn).element, settings); toolbar.append(row); - let shown = false; - try { shown = localStorage.getItem('freevideo.view-guide') === '1'; } catch { /* Show once this session. */ } - if (!shown) { - const guide = document.createElement('div'); guide.className = 'fv-view-guide'; - const text = document.createElement('span'); - text.textContent = t('Switching views keeps the content of each view.', '切换视图时,各视图的内容都会保留。'); - const dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.textContent = t('Got it', '知道了'); - dismiss.onclick = () => { - try { localStorage.setItem('freevideo.view-guide', '1'); } catch {} - // Fold the tip away rather than letting the toolbar jump. - const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; - if (reduce || typeof guide.animate !== 'function') { guide.remove(); return; } - // The tip set the toolbar's width; contract the bar in the same motion. - const before = toolbar.getBoundingClientRect().width; - guide.style.display = 'none'; - const after = toolbar.getBoundingClientRect().width; - guide.style.display = ''; - const height = guide.getBoundingClientRect().height; - guide.style.overflow = 'hidden'; guide.style.whiteSpace = 'nowrap'; - const timing = {duration: 260, easing: 'cubic-bezier(.4,0,.2,1)'}; - if (Math.abs(before - after) > 1) toolbar.animate([{width: `${before}px`}, {width: `${after}px`}], timing); - // The text is gone before the bar has narrowed enough to clip it. - guide.animate([{opacity: 1, height: `${height}px`, marginTop: '10px'}, {opacity: 0, offset: .4}, - {opacity: 0, height: '0px', marginTop: '0px', paddingTop: '0px', paddingBottom: '0px'}], - {...timing, fill: 'forwards'}).finished.then(() => guide.remove(), () => guide.remove()); - }; - guide.append(text, dismiss); toolbar.append(guide); - } toolbar.append(createUpdateNotice(cn).element); document.body.append(toolbar); refreshNavigation(); makeMovable(toolbar); From 5d68feb187dabac4b2950d17c32bcc78b31ee72f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 11:59:42 +0000 Subject: [PATCH 37/53] Progress keeps reporting during the upscale before the second pass --- freevideo_engine/comfy_bridge.py | 4 +- freevideo_engine/macos_runtime.py | 7 +- freevideo_engine/sampling_progress.py | 36 +++++++- freevideo_engine/worker.py | 120 +++++++++++++------------- web/generation_progress.js | 7 +- 5 files changed, 105 insertions(+), 69 deletions(-) diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index fa843f4b..dcf2fcf7 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -980,8 +980,8 @@ def progress_message(event): done, total = event.get('completed_steps', 8), event.get('total', 10) return dict(label='Upscaling before the second pass', phase='sampling', stage='latent_upscale', done=done, total=total, display_fraction=done / total, timing_phase='sampling', - estimated_step_seconds=None, step_elapsed_seconds=0., remaining_seconds=None, - uniform_remaining_steps=False) + estimated_step_seconds=None, step_elapsed_seconds=event.get('step_elapsed_seconds', 0.), + remaining_seconds=None, uniform_remaining_steps=False) if name == 'ram_budget_warning': # Kept in resource logs and diagnostics, not in generation progress: # crossing a soft estimate while memory is available needs no action. diff --git a/freevideo_engine/macos_runtime.py b/freevideo_engine/macos_runtime.py index 2c8eebe8..bb9277c8 100644 --- a/freevideo_engine/macos_runtime.py +++ b/freevideo_engine/macos_runtime.py @@ -53,11 +53,12 @@ def upscale_latents(video, sampling, base, backend, checkpoint=None): import torch from .latent_upscale import upscale from .two_pass import UPSCALER, crop_latents - print(json.dumps(dict(event='latent_upscale', completed_steps=sampling['base_steps'], - total=sampling['total_steps'])), flush=True) + from .sampling_progress import elapsed_reports + event = dict(event='latent_upscale', completed_steps=sampling['base_steps'], total=sampling['total_steps']) + print(json.dumps(event), flush=True) checkpoint = checkpoint or Path(base).parent / 'latent_upscaler' / Path(UPSCALER['file']).name tick = time.monotonic() - with torch.no_grad(): + with torch.no_grad(), elapsed_reports(lambda report: print(json.dumps(report), flush=True), event): backend.empty_cache() lifted, stats = upscale(video.to('mps'), checkpoint, sampling['upscale_target']['width'], sampling['upscale_target']['height'], memory_saving=True) diff --git a/freevideo_engine/sampling_progress.py b/freevideo_engine/sampling_progress.py index a2c85e19..63558cd6 100644 --- a/freevideo_engine/sampling_progress.py +++ b/freevideo_engine/sampling_progress.py @@ -6,6 +6,7 @@ """ from contextlib import contextmanager import json +import threading import time @@ -13,6 +14,23 @@ def emit_event(event): print(json.dumps(event), flush=True) +@contextmanager +def elapsed_reports(emit, fields, *, interval=3., clock=time.perf_counter): + """Report `step_elapsed_seconds` every `interval` seconds while a stage with + no internal counters (the latent upscale) runs in the calling thread.""" + started, stopped = clock(), threading.Event() + def report(): + while not stopped.wait(interval): + emit(dict(fields, step_elapsed_seconds=clock() - started)) + thread = threading.Thread(target=report, name='stage-elapsed-reports', daemon=True) + thread.start() + try: + yield + finally: + stopped.set() + thread.join(timeout=1.) + + def _bounded(value, low=0., high=1.): """Return a finite progress fraction without ever claiming completion.""" try: @@ -35,6 +53,9 @@ def __init__(self, steps, blocks, emit=emit_event, clock=time.perf_counter, *, o self.step_started = self.started self.completed = [] self.last_layer_event = float('-inf') + # When the current step entered its first layer. Input preparation and + # model reacquisition before it are not layer time. + self.first_layer_at = None def _step_estimate(self): # The first step includes input preparation and kernel compilation. A @@ -73,14 +94,20 @@ def layer(self, index): if len(self.completed) >= self.steps: return now = self.clock() + if index == 1: + self.first_layer_at = now if index == 1 or now-self.last_layer_event >= 1.: self.last_layer_event = now estimate = self._visual_step_estimate() # A layer event is emitted before that layer runs. Once the next - # layer is entered, the elapsed time covers the preceding layers; - # use that fraction as an early, continuously corrected ruler. - if estimate is None and index > 1 and self.blocks > 0: - elapsed = now - self.step_started + # layer is entered, the time since the first layer was entered is + # the measured duration of the preceding layers; use that fraction + # as an early, continuously corrected ruler. Nothing is shown + # before the first layer has really run: time spent preparing the + # step's inputs would otherwise inflate the first estimate many + # times over. + if estimate is None and index > 1 and self.blocks > 0 and self.first_layer_at is not None: + elapsed = now - self.first_layer_at fraction = (index - 1) / self.blocks if elapsed > 0 and fraction > 0: estimate = elapsed / fraction @@ -104,6 +131,7 @@ def complete(self, seconds): # elapsed time into the next one. next_step_elapsed_seconds=0.) self.step_started = self.clock() + self.first_layer_at = None @contextmanager def layers(self, blocks): diff --git a/freevideo_engine/worker.py b/freevideo_engine/worker.py index 650ba9b0..537c0c09 100644 --- a/freevideo_engine/worker.py +++ b/freevideo_engine/worker.py @@ -417,67 +417,71 @@ def sampling_complete(latents, audio, sampled): save(request['metrics'], metrics) print(json.dumps(dict(event='latent_upscale', completed_steps=sampling_plan['base_steps'], total=sampling_plan['total_steps'])), flush=True) - tick = time.perf_counter() - if live_budget is not None: - refresh_budget('upscale') - need = upscale_workspace(sampling_plan['upscale_target']) if sampling_plan['upscaler_sha256'] else 0 - # Its weights go straight to the GPU. Charging 2 GiB of - # host RAM evicted the second pass's own engine on - # tight-RAM machines, which then reloaded every block. - from .two_pass import UPSCALER_HOST_BYTES - if need and resident is not None and not resident.ram_fits(UPSCALER_HOST_BYTES): - resident.make_room('latent_upscaler', need, ram_need=UPSCALER_HOST_BYTES) - torch.cuda.empty_cache() - def upscale_available(): - free, _ = torch.cuda.mem_get_info() - budget = metrics['device_memory'].get('effective_allocator_limit_bytes') or request['gpu_budget_bytes'] - return min(free - request.get('gpu_reserve_bytes', 0), budget - torch.cuda.memory_allocated()) - available = upscale_available() - # Prefer buffer reuse before discarding useful host weights. - # The estimate selects this path, not permission to run. - # Actual allocation remains bounded by the worker's CUDA - # allocator limit. Keep the completed first-pass latents. - memory_saving = available < need - torch.cuda.reset_peak_memory_stats() - lift_canvas = sampling_plan['upscale_target'] - retry_upscale = False - upscale_failures = [] - metrics['latent_upscale'] = dict(estimated_workspace_bytes=need, - available_workspace_bytes=max(0, available), attempted_below_estimate=memory_saving, - buffer_reuse=memory_saving, allocation_retries=0, allocation_failures=upscale_failures) - save(request['metrics'], metrics) - try: - kwargs = dict(memory_saving=True) if memory_saving else {} - lifted_video, lifted = upscale(latents, request.get('upscaler_checkpoint'), - lift_canvas['width'], lift_canvas['height'], **kwargs) - except Exception as error: - from .adaptive import classify_failure - if classify_failure(error)['kind'] != 'gpu_oom' or (released(engine) and memory_saving): - raise - from .diagnostic_resources import error_details - upscale_failures.append(error_details(error)) - retry_upscale = True - if retry_upscale: - # Leave the failed call's traceback before retrying so - # its tensors no longer occupy the allocator. Do not - # sample again or change the temporal normalization. - if not released(engine): - if resident is not None: - resident.drop('engine', 'Latent upscale allocation needs additional workspace') - else: - engine.close() - if resident is not None: + from .sampling_progress import elapsed_reports + with elapsed_reports(lambda event: print(json.dumps(event), flush=True), + dict(event='latent_upscale', completed_steps=sampling_plan['base_steps'], + total=sampling_plan['total_steps'])): + tick = time.perf_counter() + if live_budget is not None: + refresh_budget('upscale') + need = upscale_workspace(sampling_plan['upscale_target']) if sampling_plan['upscaler_sha256'] else 0 + # Its weights go straight to the GPU. Charging 2 GiB of + # host RAM evicted the second pass's own engine on + # tight-RAM machines, which then reloaded every block. + from .two_pass import UPSCALER_HOST_BYTES + if need and resident is not None and not resident.ram_fits(UPSCALER_HOST_BYTES): resident.make_room('latent_upscaler', need, ram_need=UPSCALER_HOST_BYTES) - import gc - gc.collect() torch.cuda.empty_cache() - metrics['latent_upscale'].update(buffer_reuse=True, allocation_retries=1) + def upscale_available(): + free, _ = torch.cuda.mem_get_info() + budget = metrics['device_memory'].get('effective_allocator_limit_bytes') or request['gpu_budget_bytes'] + return min(free - request.get('gpu_reserve_bytes', 0), budget - torch.cuda.memory_allocated()) + available = upscale_available() + # Prefer buffer reuse before discarding useful host weights. + # The estimate selects this path, not permission to run. + # Actual allocation remains bounded by the worker's CUDA + # allocator limit. Keep the completed first-pass latents. + memory_saving = available < need + torch.cuda.reset_peak_memory_stats() + lift_canvas = sampling_plan['upscale_target'] + retry_upscale = False + upscale_failures = [] + metrics['latent_upscale'] = dict(estimated_workspace_bytes=need, + available_workspace_bytes=max(0, available), attempted_below_estimate=memory_saving, + buffer_reuse=memory_saving, allocation_retries=0, allocation_failures=upscale_failures) save(request['metrics'], metrics) - lifted_video, lifted = upscale(latents, request.get('upscaler_checkpoint'), - lift_canvas['width'], lift_canvas['height'], memory_saving=True) - latents = lifted_video - del lifted_video - latents = crop_latents(latents, sampling_plan) + try: + kwargs = dict(memory_saving=True) if memory_saving else {} + lifted_video, lifted = upscale(latents, request.get('upscaler_checkpoint'), + lift_canvas['width'], lift_canvas['height'], **kwargs) + except Exception as error: + from .adaptive import classify_failure + if classify_failure(error)['kind'] != 'gpu_oom' or (released(engine) and memory_saving): + raise + from .diagnostic_resources import error_details + upscale_failures.append(error_details(error)) + retry_upscale = True + if retry_upscale: + # Leave the failed call's traceback before retrying so + # its tensors no longer occupy the allocator. Do not + # sample again or change the temporal normalization. + if not released(engine): + if resident is not None: + resident.drop('engine', 'Latent upscale allocation needs additional workspace') + else: + engine.close() + if resident is not None: + resident.make_room('latent_upscaler', need, ram_need=UPSCALER_HOST_BYTES) + import gc + gc.collect() + torch.cuda.empty_cache() + metrics['latent_upscale'].update(buffer_reuse=True, allocation_retries=1) + save(request['metrics'], metrics) + lifted_video, lifted = upscale(latents, request.get('upscaler_checkpoint'), + lift_canvas['width'], lift_canvas['height'], memory_saving=True) + latents = lifted_video + del lifted_video + latents = crop_latents(latents, sampling_plan) lifted.update(stage_seconds=time.perf_counter() - tick, crop=dict(sampling_plan['crop']), estimated_workspace_bytes=need, diff --git a/web/generation_progress.js b/web/generation_progress.js index 6c32e8a7..9c3b5d93 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -802,9 +802,12 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f } tick(); return; } - if (!heartbeat && (previousProgress === null || progressFields.some(key => message[key] !== previousProgress[key]))) + // The upscale before the second pass has no counters; its seconds in + // progress are the engine's proof of life. + const counted = message.stage === 'latent_upscale' ? [...progressFields, 'step_elapsed_seconds'] : progressFields; + if (!heartbeat && (previousProgress === null || counted.some(key => message[key] !== previousProgress[key]))) lastProgressAt = previousProgress === null ? receivedAt : Math.max(lastProgressAt, receivedAt); - if (!heartbeat) previousProgress = Object.fromEntries(progressFields.map(key => [key, message[key]])); + if (!heartbeat) previousProgress = Object.fromEntries(counted.map(key => [key, message[key]])); if (message.stage === 'latent_upscale' || message.stage === 'first_pass_reused' || message.phase === 'refinement' || message.retry?.reuse_first_pass === true || (message.phase === 'sampling' && samplingPlan.enabled && !samplingPlan.preview From d446ea9c905c41f532b278777f6879250532e48a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:02:53 +0000 Subject: [PATCH 38/53] Agent chat picture cards open a zoomable viewer --- web/autopilot_chat.css | 12 +++++ web/autopilot_chat.js | 109 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 41d8974b..db8f83b2 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -76,6 +76,18 @@ .fv-chat-seek::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;margin-top:-3px;border-radius:50%;background:#fff} .fv-chat-seek::-moz-range-track{height:3px;border-radius:9px;background:#ffffff40}.fv-chat-seek::-moz-range-thumb{width:9px;height:9px;border:0;border-radius:50%;background:#fff} .fv-chat-info{display:grid;align-content:space-between;gap:12px;min-width:0;padding:4px 4px 2px 0} +/* Finished cards have only a heading and any available actions; an empty footer takes no room. */ +.fv-chat-job[data-kind=image][data-mode=done] .fv-chat-preview{max-width:calc(160px * var(--fv-chat-ratio,1.7778))} +.fv-chat-job[data-mode=done]:not(:has(.fv-chat-preview)){grid-template-columns:minmax(0,1fr)} +/* The picture viewer uses the same modal, surfaces and ordinary action buttons as the conversation. */ +.fv-studio.fv-chat-image-viewer[open]{display:grid;grid-template-rows:auto minmax(0,1fr);gap:12px;padding:12px;border-radius:14px;background:var(--fv-card)} +.fv-chat-image-viewer>.fv-chat-actions{justify-content:flex-end} +.fv-studio.fv-chat-image-viewer .fv-close{min-height:34px;width:34px;padding:0;font-size:20px;line-height:1;background:transparent;border-color:transparent;color:var(--fv-muted)} +.fv-studio.fv-chat-image-viewer .fv-close:hover{background:var(--fv-raised);color:var(--fv-ink)} +.fv-chat-preview[data-open=true]{cursor:zoom-in} +.fv-chat-image-stage{position:relative;min-width:0;min-height:0;overflow:hidden;border-radius:9px;background:var(--fv-bg);touch-action:none;cursor:grab;user-select:none} +.fv-chat-image-stage[data-dragging=true]{cursor:grabbing} +.fv-chat-image-stage>img{position:absolute;left:50%;top:50%;max-width:none;max-height:none;pointer-events:none} .fv-chat-info h3{margin:0;font-size:15px;line-height:1.4;font-weight:var(--fv-semibold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-chat-info .fv-chat-meta{margin-top:3px} .fv-chat-parts{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:4px;margin-bottom:7px} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 519ad547..39cccda6 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -1,6 +1,8 @@ // The conversation with the connected agent: what the user says, what the agent answers, and the agent's jobs as // cards at the place they happened. Reads and sends use the page session; the agent's token never enters the page. import {agentGet, agentPost} from './agent_session.js'; +import {isolateDialogKeys, routeDialogKey} from './dialog_keys.js'; +import {closeDialog} from './motion.js'; import { estimatedFraction, remainingAbove, remainingShown } from './generation_progress.js'; import {canDeleteConversation, conversationMenu, emptyConversations, hasConversations, mergeConversations, removeConversation, runningElsewhere, selectConversation} from './autopilot_conversations.js'; import {renderImageFailure} from './image_generation.js'; @@ -135,6 +137,79 @@ function newId() { return 'm-' + [...bytes].map(b => b.toString(36).padStart(2, '0')).join('').slice(0, 20); } +// A picture is viewed here, independently of the video library. The image stays within reach while zoomed. +function imageViewer(t, src, name, opener, failed) { + const dialog = el('dialog', 'fv-studio fv-chat-image-viewer'), actions = el('div', 'fv-chat-actions'); + dialog.setAttribute('aria-label', name); + const close = press('fv-close', '×', () => closeDialog(dialog)); + close.setAttribute('aria-label', t('Close', '关闭')); close.title = t('Close', '关闭'); actions.append(close); + const stage = el('div', 'fv-chat-image-stage'), image = el('img'); + image.alt = name; image.draggable = false; stage.append(image); dialog.append(actions, stage); + let scale = 1, fit = 1, x = 0, y = 0, disposed = false; + let frameWidth = 0, frameHeight = 0, pictureWidth = 0, pictureHeight = 0; + const pointers = new Map(); + const draw = () => { + const limitX = Math.max(0, (image.naturalWidth * scale - stage.clientWidth) / 2); + const limitY = Math.max(0, (image.naturalHeight * scale - stage.clientHeight) / 2); + x = Math.max(-limitX, Math.min(limitX, x)); y = Math.max(-limitY, Math.min(limitY, y)); + image.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${scale})`; + }; + const resize = () => { + const w = stage.clientWidth, h = stage.clientHeight, iw = image.naturalWidth, ih = image.naturalHeight; + if (!iw || !ih || !w || !h) return; + // The observer's first notification can follow the first gesture; do not reset zoom for the same size. + if (w === frameWidth && h === frameHeight && iw === pictureWidth && ih === pictureHeight) return; + frameWidth = w; frameHeight = h; pictureWidth = iw; pictureHeight = ih; + fit = Math.min(w / iw, h / ih); + scale = fit; x = y = 0; draw(); + }; + const zoom = (factor, cx, cy) => { + const next = Math.max(fit, Math.min(fit * 16, scale * factor)), ratio = next / scale; + x = cx - (cx - x) * ratio; y = cy - (cy - y) * ratio; scale = next; draw(); + }; + stage.addEventListener('wheel', event => { + event.preventDefault(); + const rect = stage.getBoundingClientRect(), unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? stage.clientHeight : 1; + zoom(Math.exp(-event.deltaY * unit / 500), event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2); + }, {passive: false}); + const gesture = () => { + const [a, b] = [...pointers.values()], rect = stage.getBoundingClientRect(); + return {cx: (a.x + b.x) / 2 - rect.left - rect.width / 2, cy: (a.y + b.y) / 2 - rect.top - rect.height / 2, + distance: Math.hypot(a.x - b.x, a.y - b.y)}; + }; + stage.onpointerdown = event => { + if (event.button !== 0) return; + pointers.set(event.pointerId, {x: event.clientX, y: event.clientY}); + stage.setPointerCapture(event.pointerId); stage.dataset.dragging = 'true'; + }; + stage.onpointermove = event => { + const previous = pointers.get(event.pointerId); if (!previous) return; + const before = pointers.size === 2 ? gesture() : null; + pointers.set(event.pointerId, {x: event.clientX, y: event.clientY}); + if (before) { + const after = gesture(); x += after.cx - before.cx; y += after.cy - before.cy; + zoom(before.distance > 0 ? after.distance / before.distance : 1, after.cx, after.cy); + } else { x += event.clientX - previous.x; y += event.clientY - previous.y; draw(); } + }; + stage.onpointerup = stage.onpointercancel = stage.onlostpointercapture = event => { + pointers.delete(event.pointerId); stage.dataset.dragging = String(pointers.size > 0); + }; + // Keep ComfyUI's document shortcuts out of this modal while retaining native Tab/Enter behavior. + const stopKeys = isolateDialogKeys(dialog, event => routeDialogKey(event, dialog)); + const observer = new ResizeObserver(resize); + const cleanup = () => { + if (disposed) return; + disposed = true; observer.disconnect(); stopKeys(); pointers.clear(); image.onload = image.onerror = null; + dialog.remove(); if (opener.isConnected && !opener.disabled) opener.focus({preventScroll: true}); + }; + dialog.addEventListener('cancel', event => { event.preventDefault(); closeDialog(dialog); }); + dialog.addEventListener('close', cleanup, {once: true}); + image.onload = () => { image.style.width = `${image.naturalWidth}px`; image.style.height = `${image.naturalHeight}px`; resize(); }; + image.onerror = () => { failed(); closeDialog(dialog); }; + document.body.append(dialog); dialog.showModal(); observer.observe(stage); close.focus(); image.src = src; + return () => { if (dialog.open) dialog.close(); cleanup(); }; +} + // open(job) shows a finished video in Creations, edit(job) takes it to the editing view. shown() says whether the // user can see the conversation; the page calls sync() whenever that may have changed. changed(view) follows every // answer: {known, agent, current, active, any}. @@ -148,6 +223,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () sendButton.type = 'submit'; sendButton.setAttribute('aria-label', t('Send', '发送')); sendButton.append(glyph('M12 19V5M5 12l7-7 7 7')); form.append(input, sendButton); scroller.append(log); main.append(scroller, down, form); element.append(main); + let dismissImage = null; let state = emptyChat(), pending = [], known = false, stopped = false, timer = null, busy = false, failures = 0; let conversations = emptyConversations(), selectedSession, selection = 0, reread = false, focusSession = null, creating = false; // Only the reading position is persisted, scoped to this server; drafts stay in this open page. @@ -511,7 +587,11 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } function preview(job) { const box = el('div', 'fv-chat-preview'); - const shape = (w, h) => { if (w > 0 && h > 0) { box.style.aspectRatio = `${w} / ${h}`; box.dataset.shape = w > h * 1.1 ? 'wide' : 'tall'; } }; + const shape = (w, h) => { + if (!(w > 0 && h > 0)) return; + box.style.aspectRatio = `${w} / ${h}`; box.dataset.shape = w > h * 1.1 ? 'wide' : 'tall'; + if (job.kind === 'image') box.style.setProperty('--fv-chat-ratio', w / h); + }; shape(job.width, job.height); return {box, shape}; } @@ -558,17 +638,25 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () function done(item, job) { const info = el('div', 'fv-chat-info'), head = el('div'), name = el('h3'), meta = el('p', 'fv-chat-meta'); head.append(name, meta); const foot = el('div', 'fv-chat-actions'); - const show = press('fv-chat-open', t('Open', '打开'), () => open(job)); + const picture = job.kind === 'image'; + const show = press('fv-chat-open', t('Open', '打开'), () => { + if (!picture) return open(job); + dismissImage?.(); + dismissImage = imageViewer(t, api.apiURL(job.thumbnail), name.textContent, show, () => { show.disabled = true; }); + }); const cut = press('fv-chat-edit', t('Edit', '剪辑'), () => edit(job)); - if (job.work) foot.append(show, cut); - info.append(head, foot); + if (picture && job.thumbnail) { show.disabled = true; foot.append(show); } + else if (!picture && job.work) foot.append(show, cut); + info.append(head); if (foot.childElementCount) info.append(foot); const children = [info]; if (job.thumbnail || job.play) { const {box, shape} = preview(job), length = el('span', 'fv-chat-length', job.seconds > 0 ? clock(job.seconds) : ''); if (job.thumbnail) { - const image = el('img'); image.alt = ''; image.src = api.apiURL(job.thumbnail); - image.onload = () => { if (!box.style.aspectRatio) shape(image.naturalWidth, image.naturalHeight); }; - box.append(image); + const image = el('img'); image.alt = ''; + const viewable = on => { show.disabled = !on; if (on) box.dataset.open = 'true'; else delete box.dataset.open; }; + image.onload = () => { if (!box.style.aspectRatio) shape(image.naturalWidth, image.naturalHeight); if (picture) viewable(true); }; + if (picture) { image.onerror = () => viewable(false); box.onclick = () => { if (!show.disabled) show.click(); }; } + image.src = api.apiURL(job.thumbnail); box.append(image); } if (job.play) { // The video plays inside its frame, with its sound; the line under it follows and can be dragged. @@ -595,6 +683,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () item.replaceChildren(...children); return (next, latest) => { name.textContent = jobName(next, t); meta.textContent = jobMeta(next, t); meta.hidden = !meta.textContent; + if (picture) show.setAttribute('aria-label', t('Open ', '打开 ') + jobName(next, t)); show.classList.toggle('fv-primary', latest); }; } @@ -610,6 +699,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () : WORKING.includes(job.status) ? 'quiet' : 'none'; if (next !== mode) { mode = next; item.dataset.job = job.id; item.dataset.mode = next.split(':')[0]; item.hidden = next === 'none'; + if (job.kind === 'image') item.dataset.kind = 'image'; else delete item.dataset.kind; if (next !== 'working') { stopping.delete(job.id); stopErrors.delete(job.id); } if (next === 'working') apply = working(item, job); else if (next.startsWith('done')) apply = done(item, job); @@ -689,7 +779,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () pending = pending.filter(entry => entry.sending || entry.session !== state.session || !ids.has(entry.id)); const localPending = pending.filter(entry => entry.session === state.session && !ids.has(entry.id)); const said = entries.some(entry => entry.type !== 'message' || entry.role === 'user') || localPending.length > 0; - const finished = entries.filter(entry => entry.type === 'job' && state.jobs[entry.job]?.status === 'succeeded' && state.jobs[entry.job].work).at(-1); + const finished = entries.filter(entry => entry.type === 'job' && state.jobs[entry.job]?.status === 'succeeded' && (state.jobs[entry.job].work || (state.jobs[entry.job].kind === 'image' && state.jobs[entry.job].thumbnail))).at(-1); const wanted = []; if (!entries.length && !localPending.length) wanted.push(['hello', 'hello', null]); let lastUser = null; @@ -815,6 +905,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // Both a server-side session change and a list selection reset the same reader. Job clocks stay keyed by job id. function resetChat() { + dismissImage?.(); dismissImage = null; state = emptyChat(); nodes.clear(); log.querySelectorAll('video').forEach(video => video.pause()); log.replaceChildren(); pinned = true; } @@ -905,7 +996,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () try { await Promise.all([...jobs.values()].map(({job, session}) => cancelJob(job, session))); } finally { bulkStopping = false; if (!stopped) { report(); void poll(); } } }, - stop() { closeMenu(); stopped = true; closed.abort(); clearInterval(moving); clearTimeout(timer); timer = null; + stop() { closeMenu(); stopped = true; dismissImage?.(); closed.abort(); clearInterval(moving); clearTimeout(timer); timer = null; document.removeEventListener('visibilitychange', visible); document.removeEventListener('pointerdown', outside, true); document.removeEventListener('keydown', keys, true); window.removeEventListener('resize', resize); list.removeEventListener('scroll', listScroll); log.querySelectorAll('video').forEach(video => video.pause()); }}; From db8840e634fe000555bd9dad6d68c13f5190cfa9 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:15:08 +0000 Subject: [PATCH 39/53] Timeline links show their kind --- web/editor_tracks.css | 2 ++ web/editor_tracks.js | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/web/editor_tracks.css b/web/editor_tracks.css index a816122d..1082080e 100644 --- a/web/editor_tracks.css +++ b/web/editor_tracks.css @@ -27,6 +27,8 @@ .fv-ed-clip.fv-ed-joined-left{border-top-left-radius:0;border-bottom-left-radius:0} .fv-ed-clip.fv-ed-joined-left[data-state=planned],.fv-ed-clip.fv-ed-joined-left[data-state=failed]{border-left-width:0} .fv-ed-clip.fv-ed-joined-right{border-top-right-radius:0;border-bottom-right-radius:0;border-right:1px solid var(--fv-border)} +.fv-ed-seam-mark{position:absolute;left:4px;bottom:4px;z-index:2;width:18px;height:18px;display:flex;align-items:center;justify-content:center;border-radius:9px;background:color-mix(in srgb,var(--fv-bg) 78%,transparent);color:var(--fv-ink);pointer-events:none} +.fv-ed-seam-mark svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round} .fv-ed-clip-media,.fv-ed-progress-fill,.fv-ed-block-progress{position:absolute;inset:0;pointer-events:none} .fv-ed-clip-media{background-repeat:repeat-x;background-position:left center;background-size:auto 100%} .fv-ed-waveform{width:100%;height:100%;display:block;stroke:color-mix(in srgb,var(--fv-accent) 55%,transparent);stroke-width:1;fill:none} diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 2ee0920f..72572c53 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -132,6 +132,7 @@ const icon = kind => { svg.setAttribute('viewBox', '0 0 20 20'); svg.setAttribute('aria-hidden', 'true'); const path = document.createElementNS(svg.namespaceURI, 'path'); path.setAttribute('d', kind === 'video' ? 'M2 4h16v12H2z M4 13l4-5 4 4 2-2 3 4' + : kind === 'link' ? 'M8.5 11.5l3-3 M7.5 9.5l-1.5 1.5a3 3 0 0 0 4.2 4.2l1.5-1.5 M12.5 10.5l1.5-1.5a3 3 0 0 0-4.2-4.2l-1.5 1.5' : kind === 'warning' ? 'M10 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16 M10 6v5 M10 14v.1' : 'M2 8h4l4-4v12l-4-4H2z M13 6c4 2 4 6 0 8 M13 9c1 1 1 1 0 2'); svg.append(path); return svg; @@ -494,10 +495,20 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = item.element.dataset.state = visual; item.element.dataset.track = track.id; item.element.dataset.busy = String(busy(clip)); item.element.dataset.extend = String(clip.extend && actions.generationAvailable?.() !== false && !busy(clip)); // A part not generated yet is attached only to the part it continues; drawn on its own it stays a separate block. - const attached = (left, right) => !!left && !!right && left.start + left.frames === right.start - && (right.pending === null || model.continuesBefore(state.record, right.id)); - item.element.classList.toggle('fv-ed-joined-left', attached(track.clips[index - 1], clip)); + // Touching blocks are one video only when the later one continues the earlier one, or both are consecutive stretches of one source; + // two separate shots that merely touch are drawn apart. + const touching = (left, right) => !!left && !!right && left.start + left.frames === right.start; + const sameStretch = (left, right) => !!left.source && sourceKey(left) === sourceKey(right) && left.in + left.frames === right.in; + const attached = (left, right) => touching(left, right) && (model.continuesBefore(state.record, right.id) || sameStretch(left, right)); + const before = track.clips[index - 1]; + item.element.classList.toggle('fv-ed-joined-left', attached(before, clip)); item.element.classList.toggle('fv-ed-joined-right', attached(clip, track.clips[index + 1])); + // On the picture track a continuation carries a link; a separate shot is told apart by being drawn apart. + const seam = track.id !== 'v1' || !touching(before, clip) ? null : model.continuesBefore(state.record, clip.id) ? 'continued' : sameStretch(before, clip) ? null : 'separate'; + let mark = item.element.querySelector(':scope > .fv-ed-seam-mark'); + if (seam === 'continued' && pixels(clip.frames) >= 26) { + if (!mark) { mark = create('span', 'fv-ed-seam-mark'); mark.setAttribute('aria-hidden', 'true'); mark.dataset.kind = seam; mark.append(icon('link')); item.element.append(mark); } + } else mark?.remove(); const failureText = visual === 'failed' ? actions.failureText?.(clip.id) || actions.reasonText?.(clip.reason) || clip.reason || t('Generation failed', '生成时出错') : ''; if (visual === 'failed') item.status.replaceChildren(icon('warning'), create('span', 'fv-ed-clip-failure-reason', failureText)); else item.status.replaceChildren(); @@ -505,8 +516,9 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = done: t('Complete', '完成'), failed: failureText}[visual]; const seconds = Math.round(clip.frames / model.FPS); item.element.title = ''; - item.element.setAttribute('aria-label', t(`${name}, clip ${index + 1}, ${seconds} s, ${status}`, - `${name},第 ${index + 1} 段,${seconds} 秒,${status}`)); + const seamText = seam === 'continued' ? t(', continues the clip before', ',接续上一段') : seam === 'separate' ? t(', separate shot', ',独立镜头') : ''; + item.element.setAttribute('aria-label', t(`${name}, clip ${index + 1}, ${seconds} s, ${status}${seamText}`, + `${name},第 ${index + 1} 段,${seconds} 秒,${status}${seamText}`)); // Pending replacements can retain their old media; state styling // overlays it until the model reports that the new media is done. item.media.hidden = visual !== 'done'; From 4ae8cfa22093a9da99ceb2a400a5d2adff5e23a2 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:31:21 +0000 Subject: [PATCH 40/53] Prompt suggestions for camera and motion words --- web/health.js | 2 +- web/prompt_references.css | 3 + web/prompt_terms.js | 136 ++++++++++++++++++++++++++++++++++++++ web/studio.js | 5 +- 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 web/prompt_terms.js diff --git a/web/health.js b/web/health.js index f65766ce..aba5c180 100644 --- a/web/health.js +++ b/web/health.js @@ -16,7 +16,7 @@ const launchVisit = query.get('freevideo') === 'launch'; export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', - 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'report_issue.js', 'studio_recent.js', + 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_terms.js', 'report_issue.js', 'studio_recent.js', 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js']; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/web/prompt_references.css b/web/prompt_references.css index 47208594..476afa45 100644 --- a/web/prompt_references.css +++ b/web/prompt_references.css @@ -10,3 +10,6 @@ .fv-reference-empty{padding:18px 12px;color:var(--fv-muted,#a9b3c4)}.fv-reference-picker button{margin:0 12px 10px;padding:5px 10px;border:1px solid var(--fv-border,#3a414c);border-radius:8px;background:var(--fv-raised,#252e39);color:inherit;cursor:pointer;font:inherit} @keyframes fv-reference-appear{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}} @media(prefers-reduced-motion:reduce){.fv-reference-picker{animation:none}} +.fv-term-picker .fv-reference-options{max-height:220px} +.fv-term-option{min-height:34px;padding:5px 10px} +.fv-term-option strong{flex:none;font-size:13px} diff --git a/web/prompt_terms.js b/web/prompt_terms.js new file mode 100644 index 00000000..d00db45e --- /dev/null +++ b/web/prompt_terms.js @@ -0,0 +1,136 @@ +// Camera and motion words offered while a prompt is typed. The list is local; nothing is sent anywhere, +// a word is written only when it is chosen, and the typed language decides which form is inserted. +export const TERMS = [ + ['dolly in', '推镜头'], ['dolly out', '拉镜头'], ['pan left', '左摇镜头'], ['pan right', '右摇镜头'], + ['tilt up', '上摇镜头'], ['tilt down', '下摇镜头'], ['tracking shot', '跟拍镜头'], ['orbit shot', '环绕镜头'], + ['crane up', '升镜头'], ['crane down', '降镜头'], ['zoom in', '变焦推近'], ['zoom out', '变焦拉远'], + ['handheld shot', '手持镜头'], ['static shot', '固定镜头'], ['low angle', '仰拍'], ['high angle', '俯拍'], + ['aerial shot', '航拍'], ['close-up', '特写'], ['wide shot', '远景'], ['slow motion', '慢动作'], +].map(([en, zh]) => ({en, zh})); +const MAX_ROWS = 6, MIN_LATIN = 3, MIN_WORD_LATER = 4, MIN_CJK = 2, MAX_CJK = 6; +const CJK = /[\u3400-\u9fff]/; + +// The words ending at `caret` that begin a known term: {start, end, matches: [{term, form}]} or null. +export function termMatches(text, caret = text.length, terms = TERMS) { + const before = String(text).slice(0, caret); + if (!before || /[@<]$/.test(before)) return null; + const last = before[before.length - 1]; + if (CJK.test(last)) { + const run = before.match(/[㐀-鿿]+$/)[0]; + for (let length = Math.min(run.length, MAX_CJK); length >= MIN_CJK; length--) { + const query = run.slice(-length), found = terms.filter(term => term.zh.startsWith(query) && term.zh !== query); + if (found.length) return {start: caret - length, end: caret, matches: found.slice(0, MAX_ROWS).map(term => ({term, form: 'zh'}))}; + } + return null; + } + // One, two or three trailing words, so that "dolly i" still finds "dolly in" and "dolly " lists what follows. + const tail = before.match(/[A-Za-z][A-Za-z -]*$/); + if (!tail) return null; + const parts = tail[0].split(' '); + for (const words of [3, 2, 1]) { + if (parts.length < words) continue; + const fragment = parts.slice(-words).join(' '), query = fragment.toLowerCase(), start = caret - fragment.length; + if (query.length < MIN_LATIN || (start > 0 && /[\w@<]/.test(before[start - 1]))) continue; + const found = terms.filter(term => term.en.startsWith(query) && term.en !== query) + .concat(words === 1 && query.length >= MIN_WORD_LATER ? terms.filter(term => !term.en.startsWith(query) + && term.en.split(/[ -]/).some(word => word.startsWith(query))) : []); + if (found.length) return {start, end: caret, matches: found.slice(0, MAX_ROWS).map(term => ({term, form: 'en'}))}; + } + return null; +} + +let serial = 0; +export function attachTermSuggest(input, {t = en => en, host, suppressed = () => false} = {}) { + if (!document.querySelector('link[data-freevideo-references]')) { + const css = document.createElement('link'); css.rel = 'stylesheet'; + css.dataset.freevideoReferences = ''; css.href = new URL('./prompt_references.css', import.meta.url).href; + document.head.append(css); + } + const popup = document.createElement('div'); popup.className = 'fv-reference-picker fv-term-picker'; + popup.setAttribute('popover', 'manual'); popup.hidden = true; + const list = document.createElement('div'); list.className = 'fv-reference-options'; + list.id = `fv-term-options-${++serial}`; list.setAttribute('role', 'listbox'); + list.setAttribute('aria-label', t('Camera and motion words', '运镜与动作词')); + popup.append(list); (host || input.closest('dialog') || document.body).append(popup); + let found = null, active = -1, composing = false, disposed = false, dismissed = null; + let saved = null; + const listeners = []; + const listen = (target, name, fn, options) => { target.addEventListener(name, fn, options); listeners.push(() => target.removeEventListener(name, fn, options)); }; + function close() { + if (popup.hidePopover && popup.matches(':popover-open')) popup.hidePopover(); + popup.hidden = true; found = null; + if (saved) { + for (const [name, value] of saved) { if (value === null) input.removeAttribute(name); else input.setAttribute(name, value); } + saved = null; + } + } + function place() { + if (popup.hidden) return; + const rect = input.getBoundingClientRect(); + popup.style.width = `${Math.min(Math.max(rect.width * .6, 240), innerWidth - 24)}px`; + const height = Math.min(popup.scrollHeight, 220); + popup.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - popup.offsetWidth - 12))}px`; + popup.style.top = `${Math.max(12, rect.bottom + height + 8 < innerHeight ? rect.bottom + 6 : rect.top - height - 6)}px`; + } + function select(index) { + active = index; + for (const [i, option] of [...list.children].entries()) option.setAttribute('aria-selected', String(i === active)); + if (list.children[active]) input.setAttribute('aria-activedescendant', list.children[active].id); + else input.removeAttribute('aria-activedescendant'); + } + function choose(index) { + const hit = found?.matches[index]; if (!hit) return; + const {start, end} = found, word = hit.term[hit.form]; + const spacer = hit.form === 'en' && !/^\s/.test(input.value.slice(end)) ? ' ' : ''; + close(); input.focus(); input.setSelectionRange(start, end); + // The browser's own undo history keeps working: one undo gives back what was typed. + if (!document.execCommand('insertText', false, word + spacer)) { + input.setRangeText(word + spacer, start, end, 'end'); input.dispatchEvent(new Event('input', {bubbles: true})); + } + } + function refresh() { + if (disposed || composing || input.disabled || input.readOnly || document.activeElement !== input + || input.selectionStart !== input.selectionEnd || suppressed()) { close(); return; } + const next = termMatches(input.value, input.selectionStart); + if (!next) { dismissed = null; close(); return; } + if (dismissed && dismissed.start === next.start && dismissed.value === input.value.slice(next.start, next.end)) { close(); return; } + dismissed = null; found = next; + list.replaceChildren(); + for (const [index, hit] of next.matches.entries()) { + const option = document.createElement('div'); option.id = `${list.id}-${index}`; + option.className = 'fv-reference-option fv-term-option'; option.setAttribute('role', 'option'); + const main = document.createElement('strong'); main.textContent = hit.term[hit.form]; + option.append(main); + option.onpointerdown = e => e.preventDefault(); option.onclick = () => choose(index); + option.onpointermove = () => select(index); list.append(option); + } + const wasHidden = popup.hidden; popup.hidden = false; + if (wasHidden && popup.showPopover) popup.showPopover(); + if (!saved) { + saved = ['aria-controls', 'aria-expanded', 'aria-activedescendant'].map(name => [name, input.getAttribute(name)]); + } + input.setAttribute('aria-controls', list.id); input.setAttribute('aria-expanded', 'true'); + select(-1); place(); + } + listen(input, 'input', refresh); + listen(input, 'compositionstart', () => { composing = true; close(); }); + listen(input, 'compositionend', () => { composing = false; refresh(); }); + listen(input, 'keydown', e => { + if (composing || e.isComposing || e.keyCode === 229 || popup.hidden || !found || e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); e.stopPropagation(); const n = found.matches.length; + select(active < 0 ? (e.key === 'ArrowDown' ? 0 : n - 1) : (active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n); + } else if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey && active >= 0) { + // Nothing is highlighted until an arrow key moves into the list, so typing, Enter and Tab keep their usual meaning. + e.preventDefault(); e.stopPropagation(); choose(active); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + dismissed = {start: found.start, value: input.value.slice(found.start, found.end)}; close(); + } + }); + listen(input, 'blur', () => { if (!popup.contains(document.activeElement)) close(); }); + listen(document, 'pointerdown', e => { if (e.target !== input && !popup.contains(e.target)) close(); }); + listen(window, 'resize', place); + listen(window, 'scroll', e => { if (!popup.contains(e.target)) place(); }, true); + return {refresh, dispose() { disposed = true; close(); listeners.forEach(stop => stop()); popup.remove(); }}; +} diff --git a/web/studio.js b/web/studio.js index 4f512c8c..702026ab 100644 --- a/web/studio.js +++ b/web/studio.js @@ -17,7 +17,8 @@ import { openLibrary } from './library.js'; import { createRecentVideos } from './studio_recent.js'; import { createAgentActivity } from './agent_activity.js'; import { createStudioQueue, randomSeed } from './studio_queue.js'; -import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs } from './prompt_references.js'; +import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs, mentionAt } from './prompt_references.js'; +import { attachTermSuggest } from './prompt_terms.js'; import { reconcileLoraTriggers } from './lora_triggers.js'; import { createSamplingEffort } from './sampling_effort.js'; import { resultActions, previewBadge, icon } from './result_actions.js'; @@ -412,6 +413,8 @@ export function openStudio(node, {extendFrom = null} = {}) { mention.title = mention.getAttribute('aria-label'); mention.disabled = prompt.disabled; mention.onpointerdown = e => e.preventDefault(); promptEditor.append(mention); cleanup.push(() => references.dispose()); + const terms = attachTermSuggest(prompt, {t, host: dialog, suppressed: () => !!mentionAt(prompt.value, prompt.selectionStart, prompt.selectionEnd)}); + cleanup.push(() => terms.dispose()); const enhancer = createPromptEnhancer({node, input: prompt, editor: promptEditor, api, t, starter, onReport: row => { node.freevideoReportId = row.report_id; progress.updateReport(row); }, onChange: () => rememberPromptDraft(api, node), From b4c46ed767acea7ffd4085dbe9e36f0395ff56ea Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:37:27 +0000 Subject: [PATCH 41/53] Detailed generation progress with stage words and real percentages --- freevideo_engine/comfy_bridge.py | 18 +- freevideo_engine/decode.py | 27 ++- freevideo_engine/model_load_progress.py | 20 ++ freevideo_engine/progress_timeline.py | 6 + freevideo_engine/runtime.py | 15 +- freevideo_engine/sampling_progress.py | 12 ++ scripts/test_freevideo_entry.mjs | 5 +- web/editor.css | 6 +- web/editor.js | 8 +- web/editor_entry.js | 11 ++ web/editor_genbar.js | 13 +- web/generation_progress.js | 240 ++++++++++++++++++------ 12 files changed, 289 insertions(+), 92 deletions(-) create mode 100644 freevideo_engine/model_load_progress.py diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index dcf2fcf7..59eb37c1 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -429,7 +429,7 @@ def _phase_for(self, message, event): if self.request['flow'] == 'upscale' or self.retry_reuse_first: return 'pass2' if self.request['flow'] != 'preview' and self.request['two_pass']: - if done > base or (done == base and (name == 'sampling_start' or message.get('stage') in ('layers', 'inputs'))): + if done > base or (done == base and (name == 'sampling_start' or message.get('stage') in ('layers', 'inputs', 'kernel_prepare'))): return 'pass2' return 'pass1' return {'encoding': 'encode', 'load': 'load', 'decode': 'decode_save'}.get(phase) if isinstance(phase, str) else None @@ -493,9 +493,10 @@ def _counts_and_live(self, message, event, now): self.live_calibration = True elif phase == 'load': if event.get('event') == 'loaded': - done, total = 50, 50 + total = self.count[1] if self.count else 50 + done = total elif event.get('event') == 'prepared_blocks': - done, total = event.get('blocks'), 50 + done, total = message.get('done'), message.get('total') elif event.get('event') == 'model_load_phase': done, total = event.get('done'), event.get('total') # LoRA groups describe a substage, not completion of the whole load. @@ -1031,9 +1032,14 @@ def progress_message(event): return {'label': 'Preparing reference media', 'timing_phase': 'encoding', 'reference_trimmed': {key: event.get(key) for key in ('kind', 'number', 'seconds', 'used_seconds')}} if name == 'prepared_blocks': - # The cached video model has 50 blocks, as in the former label. - return {'label': 'Loading the video model', - 'timing_phase': 'load', 'done': event.get('blocks'), 'total': 50} + # Old engine logs omitted the total for the pinned 50-block model. + # Current workers report the actual model length, including the last + # block when that length is not a multiple of five. + result = {'label': 'Loading the video model', 'timing_phase': 'load'} + done, total = event.get('blocks'), event.get('total', 50) + if type(done) is int and type(total) is int and total > 0 and 0 <= done <= total: + result.update(done=done, total=total) + return result if name in ('sampling_preset_download', 'reference_assets_download'): reference = name == 'reference_assets_download' return dict(label='Preparing reference media resources' if reference else 'Preparing sampling preset', diff --git a/freevideo_engine/decode.py b/freevideo_engine/decode.py index 1827f3ca..cedb2228 100644 --- a/freevideo_engine/decode.py +++ b/freevideo_engine/decode.py @@ -10,21 +10,32 @@ from .vae_tiles import TileDecoder, compile_blocks +DECODE_LABELS = { + 'vae_load': 'Loading video VAE', + 'video_decode': 'Decoding the video', + 'video_postprocess': 'Processing video colors', + 'rgb_save': 'Saving decoded video', + 'audio_load_decode': 'Loading and decoding audio', + 'audio_save': 'Saving decoded audio', + 'mp4_save': 'Saving MP4 and audio', +} + + @torch.no_grad() def decode_to_file(latents, audio_latents, out_path, *, base, offload=False, prefetch=True, tile_group=False, preload=False, pin_weights=False, artifacts_dir=None, phase_callback=None, stream_output=False, stream_weights=False, resident_blocks=0, model_cache=None, linear_compute_cache=False, continuation=None, audio_lead_in_callback=None, output_size=None): + def phase(name): + if phase_callback is not None: + phase_callback(name) + print(json.dumps({'event': 'decode_phase', 'phase': DECODE_LABELS[name]}), flush=True) + phase('vae_load') from diffusers import AutoencoderKLMiniMaxH3 from diffusers.utils.export_utils import encode_video from src.inference.render import PIXEL_MEAN, PIXEL_STD, FPS started = time.perf_counter() - def phase(name): - if phase_callback is not None: - phase_callback(name) - phase('vae_load') - print(json.dumps({'event': 'decode_phase', 'phase': 'Loading video VAE'}), flush=True) vae = None weight_source = None compute_cache_metrics = None @@ -117,7 +128,6 @@ def phase(name): load_seconds = time.perf_counter() - started decode_started = time.perf_counter() phase('video_decode') - print(json.dumps({'event': 'decode_phase', 'phase': 'Decoding the video'}), flush=True) offload_stats = None rgb_mapping = None streamed_timings = {} @@ -150,10 +160,10 @@ def decoded(done, total, elapsed): gc.collect() torch.cuda.empty_cache() video_decode_seconds = streamed_timings.get('video_decode_seconds', time.perf_counter() - decode_started) - phase('video_postprocess') # Bound postprocessing memory by frame chunks; use the same FP32 operations # and rounding before converting to uint8. Do this on CPU after decoding. if not stream_output: + phase('video_postprocess') frames = torch.empty((video.shape[2], video.shape[3], video.shape[4], 3), dtype=torch.uint8) pixel_mean = torch.tensor(PIXEL_MEAN).view(1, -1, 1, 1, 1) pixel_std = torch.tensor(PIXEL_STD).view(1, -1, 1, 1, 1) @@ -179,7 +189,6 @@ def decoded(done, total, elapsed): artifact_seconds += time.perf_counter() - tick audio_start = time.perf_counter() phase('audio_load_decode') - print(json.dumps({'event': 'decode_phase', 'phase': 'Loading and decoding audio'}), flush=True) audio_vae = None if model_cache is not None: from .resident_models import key, file_identity @@ -195,6 +204,7 @@ def decoded(done, total, elapsed): audio_vae.eval().requires_grad_(False) audio_mean = torch.tensor(audio_vae.config.latents_mean, device='cuda').view(1, -1, 1) audio_std = torch.tensor(audio_vae.config.latents_std, device='cuda').view(1, -1, 1) + print(json.dumps({'event': 'decode_phase', 'phase': 'Decoding audio'}), flush=True) audio = audio_vae.decode(audio_latents * audio_std + audio_mean, return_dict=False)[0] audio = audio.float().permute(1, 0, 2)[0].cpu() rate = audio_vae.config.sampling_rate @@ -225,7 +235,6 @@ def decoded(done, total, elapsed): temporary = destination.with_name(destination.stem + '.partial' + destination.suffix) encode_started = time.perf_counter() phase('mp4_save') - print(json.dumps({'event': 'decode_phase', 'phase': 'Saving MP4 and audio'}), flush=True) if output_size is not None: # The model rendered a larger size; only the picture is scaled. from .output_fit import fit_frames diff --git a/freevideo_engine/model_load_progress.py b/freevideo_engine/model_load_progress.py new file mode 100644 index 00000000..9b7e0d0b --- /dev/null +++ b/freevideo_engine/model_load_progress.py @@ -0,0 +1,20 @@ +"""Real completed video-model blocks; no tensor reads or device imports.""" +import json + + +class PreparedBlocks: + def __init__(self, total): + if type(total) is not int or total < 1: + raise ValueError('Model block total must be a positive integer') + self.total, self.done = total, 0 + + def report(self, **fields): + print(json.dumps(dict(fields, event='prepared_blocks', blocks=self.done, total=self.total)), flush=True) + + def complete(self, **fields): + # Called only after a block's existing preparation and upload submission. + # A submission count does not claim synchronized GPU upload completion. + if self.done == self.total: + raise ValueError('All model blocks have already been prepared') + self.done += 1 + self.report(**fields) diff --git a/freevideo_engine/progress_timeline.py b/freevideo_engine/progress_timeline.py index 7fb3870e..5b492431 100644 --- a/freevideo_engine/progress_timeline.py +++ b/freevideo_engine/progress_timeline.py @@ -84,6 +84,7 @@ 'gpu', 'imports', 'inputs', + 'kernel_prepare', 'installation', 'latent_save', 'latent_upscale', @@ -123,6 +124,7 @@ 'Continuing from the preview', 'Copy local model', 'Decoding video and audio', + 'Decoding audio', 'Decoding video tiles', 'Downloading extra model weights (one-time download)', 'Encoding image/video references', @@ -183,6 +185,8 @@ 'Preparing two-pass upscaler', 'Preparing video', 'Preparing video decoding', + 'Preparing GPU programs for the first step', + 'Processing video colors', 'Preview saved', 'Prompt ready', 'RAM measurement', @@ -214,6 +218,8 @@ 'Saving MP# and audio', 'Saving completed sampling', 'Saving prompt cache', + 'Saving decoded video', + 'Saving decoded audio', 'Single-pass · # steps', 'Start ComfyUI', 'Starting ComfyUI', diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index 172dae1a..64188b4f 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -292,6 +292,9 @@ def prepare_streamed(block, index, unread=False): block_bind_seconds = 0. upload_submit_seconds = 0. block_order = list(range(len(model.transformer_blocks))) + from .model_load_progress import PreparedBlocks + block_progress = PreparedBlocks(len(block_order)) + block_progress.report() if streamed_source is not None and streamed_source.direct_read: # Pins and views are granted in the order blocks are prepared, and # the blocks RAM cannot keep are read from the drive every step. @@ -360,15 +363,9 @@ def prepare_streamed(block, index, unread=False): # unpinned bytes are reopened by SafetensorLayers on demand, # rather than accumulating until the first sampling step. prepare_streamed(block, streamed_refiner_count + index - resident_blocks, unread) - # A block whose sampling table is computed here, for a step count - # without stored tables, takes far longer than a cached one; report - # each of those so the load visibly moves. - if prepared % 5 == 0 or (adaln_cache and not cache_hit): - print(json.dumps({'event': 'prepared_blocks', 'blocks': prepared, - 'adaln_cache': adaln_cache, 'adaln_reused_blocks': table_cache_hits, - 'resident_uploaded_blocks': min(prepared, resident_blocks), - 'phase': 'Load cached blocks and upload resident weights', - 'seconds': time.perf_counter() - started}), flush=True) + block_progress.complete(adaln_cache=adaln_cache, adaln_reused_blocks=table_cache_hits, + resident_uploaded_blocks=min(prepared, resident_blocks), + phase='Load cached blocks and upload resident weights', seconds=time.perf_counter() - started) if adaln_cache: del embeddings self.schedule_hook = model.register_forward_pre_hook(self.cursor.before, with_kwargs=True) diff --git a/freevideo_engine/sampling_progress.py b/freevideo_engine/sampling_progress.py index 63558cd6..17b11699 100644 --- a/freevideo_engine/sampling_progress.py +++ b/freevideo_engine/sampling_progress.py @@ -89,6 +89,9 @@ def event(self, name, **fields): def start(self): self.event('sampling_start', stage='inputs', step=1) + # Preparation can encounter a new shape or a cold compiler cache. We + # cannot tell whether a compile is active, or how far it has progressed. + self.event('sampling_prepare', stage='kernel_prepare', step=1) def layer(self, index): if len(self.completed) >= self.steps: @@ -160,6 +163,15 @@ def progress_message(event): return dict(label=labels[phase], phase='sample_finalize', stage=phase, done=0, total=0) return None + if name == 'sampling_prepare': + done, total = event.get('completed_steps'), event.get('total') + if type(total) is not int or total < 1 or type(done) is not int or not 0 <= done < total: + return None + return dict(label='Preparing GPU programs for the first step', phase='sampling', + stage='kernel_prepare', done=done, total=total, unit='steps', + kernel_cache_note=True, + uniform_remaining_steps=event.get('uniform_remaining_steps', True), + step_elapsed_seconds=event.get('step_elapsed_seconds', 0.)) if name not in ('sampling_start', 'sampling_progress', 'step', 'loaded'): return None total = event.get('total', 8) diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 53fe1439..bc4f4ea1 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -351,11 +351,12 @@ test('main browser entry registers both views and preserves node hooks', async t const activity = progress.snapshot().label; removalNode.freevideoShowProgress({compatibility: {level: 2, name_en: 'Compatible', name_zh: '兼容'}}); assert.equal(progress.snapshot().label, activity, 'The node must not replace the activity for settings-only events'); - assert.match(progress.snapshot().status, /Compatibility setting Compatible|兼容档/); + assert.match(progress.snapshot().status, /so far|已进行/, 'pause has priority over a settings-only compatibility notice'); removalNode.freevideoShowProgress({phase: 'recovery', retry: {kind: 'ram_pressure'}}); assert.match(progress.snapshot().status, /Out of RAM|内存不足/); removalNode.freevideoShowProgress({new_request: true, phase: 'load', received_at: 0}); - assert.equal(progress.snapshot().status, '', 'A new request clears the old retry and compatibility notice'); + assert.match(progress.snapshot().status, /so far|已进行/, 'An aged new request clears retry and compatibility but shows its own pause'); + assert.doesNotMatch(progress.snapshot().status, /Out of RAM|内存不足|Compatibility setting|兼容档/); assert.equal(progress.stall.hidden, false); assert.equal(timers.size, 1); let layoutCalls = 0, timersDuringLayout; diff --git a/web/editor.css b/web/editor.css index b537f534..15e84942 100644 --- a/web/editor.css +++ b/web/editor.css @@ -63,7 +63,11 @@ .fv-ed-genbar{grid-column:1/-1;grid-row:4;display:flex;align-items:center;gap:12px;padding:0 16px;border-top:1px solid var(--fv-border);border-bottom:1px solid var(--fv-border);background:var(--fv-card);min-width:0} .fv-ed-genbar .fv-ed-description{width:auto} .fv-ed-clip[data-state=running] .fv-block-progress.fv-ed-block-progress{display:grid} -.fv-ed-gen-hint{font-size:13px;color:var(--fv-muted);flex:1;min-width:0} +.fv-ed-gen-hint{display:flex;font-size:13px;color:var(--fv-muted);flex:1;min-width:0;overflow:hidden;white-space:pre} +.fv-ed-gen-part{flex:none} +.fv-ed-gen-stage,.fv-ed-gen-time{min-width:0;overflow:hidden;text-overflow:ellipsis} +.fv-ed-gen-stage{flex:0 1000 auto} +.fv-ed-gen-time{flex:none;max-width:100%} .fv-ed-genbar[data-mode=selected] .fv-ed-gen-hint{flex:none} .fv-ed-gen-note{font-size:13px;color:var(--fv-ink);flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-ed-gen-note[hidden]{display:none} diff --git a/web/editor.js b/web/editor.js index 2efdc998..7f3211bb 100644 --- a/web/editor.js +++ b/web/editor.js @@ -209,7 +209,8 @@ export function openEditor({api, app, from = null, panel = {}, components: given function barJob(job) { const latest = [...runProgress.values()].filter(message => message.chain.id === job.id) .sort((a, b) => (a.received_at || 0) - (b.received_at || 0)).at(-1); - return latest ? {...job, chainProgress: latest.chain} : job; + const words = panel.progressWords?.() || null; + return latest ? {...job, chainProgress: latest.chain, words} : words ? {...job, words} : job; } function paintJob(job) { const messages = blockMessages([...runProgress.values()].filter(message => message.chain.id === job.id), @@ -1254,7 +1255,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given listeners.push(() => api.removeEventListener('execution_error', executionError)); } if (panel.progressElement && typeof MutationObserver !== 'undefined') { - progressObserver = new MutationObserver(() => { for (const job of jobStatuses.values()) paintJob(job); }); + progressObserver = new MutationObserver(() => { + for (const job of jobStatuses.values()) paintJob(job); + if (mostRecentJob && jobStatuses.has(mostRecentJob)) genbar?.update(barJob(jobStatuses.get(mostRecentJob))); + }); progressObserver.observe(panel.progressElement, {subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['aria-valuenow', 'hidden']}); } const bar = tracks.element.querySelector('.fv-ed-track-zoom'); diff --git a/web/editor_entry.js b/web/editor_entry.js index 3f2feb8c..2420faab 100644 --- a/web/editor_entry.js +++ b/web/editor_entry.js @@ -29,6 +29,17 @@ export function openEditorView(node, {studio, work = null, extend = false} = {}) const number = progressElement.querySelector('.fv-generation-count')?.textContent?.match(/(\d+)%/); return number ? {percent: Number(number[1])} : {percent: 0}; }; + // What the creation panel is saying now; the editor covers that panel, so its bar repeats the words. + panel.progressWords = () => { + if (progressElement.hidden) return null; + const words = selector => { + const element = progressElement.querySelector(selector); + return element && !element.hidden && element.getClientRects().length ? element.textContent.replace(/\s+/g, ' ').trim() : ''; + }; + // The block already shows the overall percentage; the bar keeps only the stage sentence. + const stage = words('.fv-generation-label').replace(/(本步 \d+%)$| \(\d+% of this step\)$/, ''); + return {stage, time: words('.fv-generation-status'), stall: words('.fv-generation-stall')}; + }; } const handle = openEditor({api, app, panel, from: work ? {work, ...(extend ? {extend: true} : {})} : null, onView(view) { if (view === 'nodes') showNodes(node, studio); diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 009c856a..137a2cf7 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -90,8 +90,17 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { const blocks = status?.blocks || [], done = blocks.filter(block => block.state === 'done').length; const number = Math.min(blocks.length || 1, Math.max(done, status?.chainProgress?.index || 0) + 1), count = blocks.length || 1; const remaining = status?.chainProgress?.overall?.remaining_seconds ?? (blocks.every(block => Number.isFinite(block.remaining_seconds)) ? blocks.reduce((sum, block) => sum + block.remaining_seconds, 0) : null); - hint.textContent = t(`Part ${number}/${count}`, `第 ${number}/${count} 段`) - + (remaining != null ? ' · ' + timeEstimate(remaining, t) : ''); + const words = status?.words; + // The creation panel's own sentences, so a long step says what it is and how long it has taken. + // At a narrow width only the stage sentence is cut; the part number and the time stay. + // A single part needs no number; with nothing else to say it reads "Preparing". + const time = words?.stall || words?.time || (remaining != null ? timeEstimate(remaining, t) : ''); + const stage = words?.stage || (count === 1 && !time ? t('Preparing', '正在准备') : ''); + const spans = [['fv-ed-gen-part', count > 1 ? t(`Part ${number}/${count}`, `第 ${number}/${count} 段`) : ''], + ['fv-ed-gen-stage', stage], ['fv-ed-gen-time', time]]; + const shown = spans.filter(([, text]) => text).map(([cls, text], index) => [cls, index ? ` · ${text}` : text]); + hint.replaceChildren(...shown.map(([cls, text]) => el('span', cls, text))); + hint.title = shown.map(([, text]) => text).join(''); } firstFrame.update(busy); references?.update(busy, busy ? undefined : !!actions.references.visible()); diff --git a/web/generation_progress.js b/web/generation_progress.js index 9c3b5d93..e6314f01 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -11,6 +11,64 @@ const duration = value => { }; const progressFields = ['phase', 'stage', 'timing_phase', 'label', 'done', 'total', 'block']; +// Iris's approved substage sentences. Unknown engine labels never reach the UI. +const SUBACTIVITY = { + start: ['Starting the generation process', '正在启动生成进程'], + libraries: ["Loading the text encoder's libraries", '正在载入文本编码器的程序库'], + weights: ['Reading the text encoder from disk', '正在从硬盘读取文本编码器'], + gpu: ['Moving the text encoder to the GPU', '正在把文本编码器载入显卡'], + prompt: ['Encoding the prompt', '正在编码提示词'], + reference: ['Encoding the reference images and videos', '正在编码参考图片和视频'], + load: ['Loading the video model', '正在载入视频模型'], + first: ['Running the first step (it takes longer the first time a size is used or after an update)', + '正在进行第一步(首次使用这个尺寸或更新后,第一步要多花一些时间)'], + upscale: ['Upscaling the first pass', '正在放大第一遍的结果'], + decoder: ['Loading the video decoder', '正在载入视频解码器'], + decode: ['Decoding the video', '正在解码视频'], + audio: ['Decoding the audio', '正在解码音频'], + save: ['Saving the video', '正在保存视频'], +}; +// [EN, ZH, EN with a step, ZH with a step]; a request that samples twice adds which pass. +const SAMPLING_NAME = { + single: ['Sampling', '正在采样', 'Sampling step', '正在采样第 '], + first: ['Sampling (first pass)', '正在采样(一采)', 'Sampling step', '正在采样第 '], + pass2: ['Sampling (second pass)', '正在采样(二采)', 'Sampling step', '正在采样第 '], + preview: ['Sampling (preview)', '正在采样(预览)', 'Sampling step', '正在采样第 '], +}; +const SAMPLING_PASS = {first: ['first pass', '一采'], pass2: ['second pass', '二采'], preview: ['preview', '预览']}; +const SUBACTIVITY_LABELS = { + 'Starting generation process': 'start', 'Starting text encoder': 'libraries', + 'Loading text encoder': 'libraries', 'Preparing text encoder GPU': 'gpu', + 'Reading text encoder weights': 'weights', 'Preparing text encoder model': 'weights', + 'Loading text encoder onto GPU': 'gpu', 'Encoding prompt': 'prompt', 'Encoding text and images': 'prompt', + 'Loading video VAE': 'decoder', 'Loading and decoding audio': 'audio', 'Saving MP4 and audio': 'save', + 'Decoding the video': 'decode', 'Processing video colors': 'decode', 'Decoding audio': 'audio', + 'Saving decoded video': 'save', 'Saving decoded audio': 'save', +}; +function subactivity(message, activity, t) { + const phase = message.phase || message.timing_phase || message.stage; + const label = message.label; + let key = SUBACTIVITY_LABELS[label]; + const sampling = ['pass1', 'pass2', 'preview'].includes(activity?.key); + if (sampling) { + // Engines send kernel_prepare before every pass's first step; its count is the first pass's steps. + if (message.done === 0 || message.stage === 'kernel_prepare') key = 'first'; + // A paused stage line carries no counts; the normal line keeps the user's steps. + else return {key: `sampling:${activity.key}`, text: t(...SAMPLING_NAME.single.slice(0, 2)), sampling: true}; + } + key ||= {prepare: phase === 'starting' ? 'start' : null, encode: LABEL_ACTIVITY.reference.includes(label) + ? 'reference' : 'prompt', load: 'load', decode: 'decode', save: 'save', upscale: 'upscale'}[activity?.key]; + if (key) return {key, text: t(...SUBACTIVITY[key]), sampling}; + // Only a download name needs the fallback frame; every other activity already reads as a sentence. + const named = activity && activity.key !== 'prepare' ? activity.text : ''; + return {key: `other:${activity?.key || 'prepare'}`, sampling, + text: !named ? t(...ACTIVITY.prepare) : activity.key === 'download' ? t(`Still working: ${named}`, `仍在进行:${named}`) : named}; +} +const waitDuration = (seconds, t) => { + const n = Math.floor(Math.max(0, seconds)), minutes = Math.floor(n / 60), rest = n % 60; + return minutes ? t(`${minutes} min${rest ? ` ${rest} s` : ''}`, rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`) + : t(`${rest} s`, `${rest} 秒`); +}; // Only our status sentences, scoped to a request; no prompt or engine log. // A reopened Studio/tab must not announce the replayed explanation again. const announcementKey = 'freevideo.progress.announcements'; @@ -37,18 +95,17 @@ const STALL_SECONDS_BY_PHASE = Object.freeze({ latent_upscale: 180, sample_finalize: 180, decode: 180, save: 180, dependencies: 180, recovery: 180, other: 180, }); -// Only these fixed stage sentences reach either progress view, with at most the -// sampling step or a download's size and speed. Engine labels, layer, block and -// frame counts and the original sampling plan remain available in the report. +// Normal stage sentences keep the sampling step or a download's size and speed. +// After 15 s of engine silence SUBACTIVITY names the work without counts. +// Raw engine labels and internal units remain available in the report. // Stage words the product already uses: settings say 一采 / 二采, results say 解码. -// No "正在": the stage line sits beside a moving clock and a small bar. const ACTIVITY = { - prepare: ['Preparing', '准备中'], - prompt: ['Encoding the prompt', '编码提示词'], - combined: ['Encoding the prompt and references', '编码提示词和参考素材'], - load: ['Loading the video model', '载入视频模型'], - decode: ['Decoding the video', '解码视频'], - save: ['Saving the video', '保存视频'], + prepare: ['Preparing', '正在准备'], + prompt: ['Encoding the prompt', '正在编码提示词'], + combined: ['Encoding the prompt and references', '正在编码提示词和参考素材'], + load: ['Loading the video model', '正在载入视频模型'], + decode: ['Decoding the video', '正在解码视频'], + save: ['Saving the video', '正在保存视频'], }; const LABEL_ACTIVITY = { prepare: ['Starting generation process', 'Preparing video', 'Checking saved video', 'Resource forecast ready'], @@ -68,9 +125,10 @@ const LABEL_ACTIVITY = { 'Low-memory mode for this GPU'], refine: ['Upscaling before the second pass', 'Continuing from the preview', 'Reusing the completed first pass'], decode: ['Loading video VAE', 'Decoding video and audio', 'Decoding the video', 'Decoding video tiles', - 'Loading and decoding audio', 'Reusing completed sampling · retrying video and audio decoding', + 'Loading and decoding audio', 'Processing video colors', 'Decoding audio', + 'Reusing completed sampling · retrying video and audio decoding', 'Checking completed sampling', 'Saving completed sampling', 'Releasing sampling buffers', 'Preparing video decoding'], - save: ['Saving MP4 and audio'], + save: ['Saving MP4 and audio', 'Saving decoded video', 'Saving decoded audio'], }; // Engine steps that run only when the request has reference media. const REFERENCE_WORK = new Set(['Encoding reference media', 'Encoding image/video references', 'Encoding reference audio', @@ -103,13 +161,18 @@ function activityLabel(message, t, plan = {}, secondPass = false, references = f } return {key: 'download', text: t(download[0], download[1]) + detail}; } - const steps = (key, en, zh, step, total) => ({key, text: Number.isInteger(step) && Number.isInteger(total) && total > 0 - ? t(`${en} · step ${step} of ${total}`, `${zh} · 第 ${step} / ${total} 步`) : t(en, zh)}); + const steps = (key, name, step, total) => { + const [en, zh, enSteps, zhSteps] = SAMPLING_NAME[name]; + const pass = SAMPLING_PASS[name]; + return {key, text: Number.isInteger(step) && Number.isInteger(total) && total > 0 + ? t(`${enSteps} ${step} of ${total}${pass ? ` (${pass[0]})` : ''}`, `${zhSteps}${step} / ${total} 步${pass ? `(${pass[1]})` : ''}`) + : t(en, zh)}; + }; const base = Number.isInteger(plan.base_steps) && plan.base_steps > 0 ? plan.base_steps : null; const second = plan.second || plan.upscale_target; if (message.prediction || message.sampling_plan) return named('prepare'); if (phase === 'recovery') return message.retry?.reuse_sampling === true ? named('decode') - : message.retry?.reuse_first_pass === true ? steps('pass2', 'Second pass', '二采') : named('prepare'); + : message.retry?.reuse_first_pass === true ? steps('pass2', 'pass2') : named('prepare'); if (phase === 'sample_finalize') return named('decode'); if (message.stage === 'latent_upscale' || phase === 'latent_upscale') return {key: 'upscale', text: Number.isInteger(second?.width) && Number.isInteger(second?.height) @@ -123,12 +186,12 @@ function activityLabel(message, t, plan = {}, secondPass = false, references = f if ((secondPass || phase === 'refinement') && twoPass) { const refine = total !== null && total > base ? total - base : plan.refine_steps; const step = done === null ? null : Math.min(Math.max(1, done - base + 1), refine); - return steps('pass2', 'Second pass', '二采', step, refine); + return steps('pass2', 'pass2', step, refine); } const count = twoPass ? base : total; const step = done === null || count === null ? null : Math.min(done + 1, count); - if (twoPass && plan.preview) return steps('preview', 'Preview', '预览', step, count); - return twoPass ? steps('pass1', 'First pass', '一采', step, count) : steps('pass1', 'Sampling', '采样', step, count); + if (twoPass && plan.preview) return steps('preview', 'preview', step, count); + return steps('pass1', twoPass ? 'first' : 'single', step, count); } const key = labelActivities.get(message.label) || { starting: 'prepare', prepare: 'prepare', prediction: 'prepare', encoding: 'combined', @@ -136,8 +199,11 @@ function activityLabel(message, t, plan = {}, secondPass = false, references = f }[phase] || labelActivities.get(message.stage); if (!key) return null; if (key === 'refine') return {key: 'upscale', text: t('Second pass · upscaling', '二采 · 放大')}; - // One sentence for the whole encoding stage. The engine names images in its - // steps either way; only a request with reference media says so. + // Named encoder and post-sampling events stay distinct, even before a pause. + const substage = SUBACTIVITY_LABELS[message.label] || (key === 'reference' ? 'reference' : null); + if (substage) return {key: ['prompt', 'combined', 'reference'].includes(key) ? 'encode' : key, + text: t(...SUBACTIVITY[substage])}; + // Older or unnamed encoding events keep the safe generic activity. if (key === 'prompt' || key === 'combined' || key === 'reference') return {key: 'encode', text: t(...ACTIVITY[references ? 'combined' : 'prompt'])}; // LoRA preparation counts LoRA files, then the model counts its own blocks: @@ -322,6 +388,8 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f let pauseAnnounced = false, lastExplanation = '', announcedMessages = new Set(); let announcementRecord = null; let remainingSeconds = null; + let detail = null, localShare = null, decodeMinutes = null, decodeMinutesAt = -Infinity; + const workShares = new Map(); let completedSteps = 0, previousRemaining = null, remainingAt = 0, lastActivity = null; // Once the shown time reaches zero before the video is saved, it stays hidden for this request. // The last minute runs on real time even while an estimate holds the number. @@ -390,7 +458,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f if (id === reportId) report.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); } finally { if (id === reportId) report.disabled = false; } }; - const counted = value => Number.isInteger(value.total) && value.total > 0 && Number.isInteger(value.done) + const hasCount = value => Number.isInteger(value.total) && value.total > 0 && Number.isInteger(value.done) && value.done >= 0 && value.done <= value.total; function drawOverall() { @@ -436,25 +504,23 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f // A segment can take several minutes. Extended silence gets a notice once; // leave the run going, and // offer the report, whose engine log shows the step it stopped at. - let stallHasReport = null, stallLoading = null; + let stallHasReport = null; + const segmentTypical = () => detail?.sampling && valid(state.estimated_step_seconds) + ? state.estimated_step_seconds : overall.typical_seconds; function drawStall(age, ended) { const floor = STALL_SECONDS_BY_PHASE[state.phase || state.timing_phase] ?? STALL_SECONDS_BY_PHASE.other; - const threshold = Math.max(floor, valid(overall.typical_seconds) ? 2 * overall.typical_seconds : 0); + const typical = segmentTypical(); + const threshold = Math.max(floor, valid(typical) ? 2 * typical : 0); const shown = !ended && age >= threshold; const wasShown = !stall.hidden; const hasReport = reportId !== null; - // Shown after 3 minutes, without saying so: the card shows two times only. - const loading = (state.phase || state.timing_phase) === 'load'; - if (shown && (stall.hidden || hasReport !== stallHasReport || loading !== stallLoading)) { - const first = loading - ? t('No loading progress for a while. Generation is still running, so you can keep waiting.', - '已经有一段时间没有新的加载进度,生成仍在进行,可以继续等待。') - : t('No progress for a while. Generation is still running, so you can keep waiting.', + // The separate notice never adds another clock or blocks the run. + if (shown && (stall.hidden || hasReport !== stallHasReport)) { + const first = t('No progress for a while. Generation is still running, so you can keep waiting.', '已经有一段时间没有新的进度,生成仍在进行,可以继续等待。'); const sentence = hasReport ? first + t( ' You can also use Download report below and submit the report in a GitHub issue; we will look into it and work on a fix for you.', '也可以点击下方的“下载报告”并提交到 GitHub issue,我们会专门排查和优化,为您解决这个问题。') : first; - stallLoading = loading; if (hasReport) { const buttonName = el('span', ''); buttonName.style.whiteSpace = 'nowrap'; buttonName.textContent = t('Download report', '“下载报告”'); @@ -491,10 +557,12 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f let holdUntil = 0, holdMode = null, holdFraction = null; function miniTarget() { const key = lastActivity?.key; - const counted = value => Number.isFinite(value?.[0]) && Number.isFinite(value?.[1]) && value[1] > 0 + const ratio = value => Number.isFinite(value?.[0]) && Number.isFinite(value?.[1]) && value[1] > 0 ? Math.max(0, Math.min(1, value[0] / value[1])) : null; if (key === 'download' || key === 'load' || key === 'lora' || key === 'decode' || key === 'encode') { - const share = counted([state.done, state.total]); + if (localShare !== null && hasCount(state) && ['load', 'decode'].includes(detail?.key)) + return {mode: 'determinate', fraction: localShare}; + const share = ratio([state.done, state.total]); if (share !== null) return {mode: 'determinate', fraction: share}; // Slow without a count (an older engine, an audio pass): sweep only. return {mode: 'indeterminate'}; @@ -517,7 +585,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f // The current stage has lasted 2 s: the stage line names it from now on. function settle(at) { if (stageId !== null && stageId !== labelId && at - stageSince >= 2000) { - labelId = stageId; labelText = lastActivity?.text ?? labelText; away = null; + labelId = stageId; labelText = lastActivity ? withWorkPercent(lastActivity.text) : labelText; away = null; } } function enterStage(id, at) { @@ -562,17 +630,22 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f if (!lastActivity) { labelId = null; return t(...ACTIVITY.prepare); } settle(now()); if (labelId === null) { labelId = stageId; away = null; } - if (labelId === stageId) labelText = lastActivity.text; + if (labelId === stageId) labelText = withWorkPercent(lastActivity.text); return labelText; } + function withWorkPercent(base) { + const percent = localShare === null ? null : Math.min(localShare === 1 ? 100 : 99, Math.floor(localShare * 100 + 1e-9)); + // Keep the percentage with its own sentence while a new stage waits to be named. + return percent !== null && (detail?.key === 'load' || detail?.key === 'decode') + ? base + t(` (${percent}% of this step)`, `(本步 ${percent}%)`) : base; + } function drawActivity(age, ended) { - const base = ended ? (overall.status === 'complete' || state.phase === 'complete' ? savedText() : t('Stopped', '已停止')) - : stageText(); const paused = !ended && age > 15; - // Two times only (elapsed, remaining): a pause shows on the small bar, not as text. - if (label.textContent !== base) label.textContent = base; + const base = ended ? (overall.status === 'complete' || state.phase === 'complete' ? savedText() : t('Stopped', '已停止')) + : paused ? detail?.text || stageText() : stageText(); + const shown = !ended && paused && detail ? withWorkPercent(base) : base; + if (label.textContent !== shown) label.textContent = shown; drawMini(age, ended); - const firstSlow = state.phase === 'sampling' && state.stage !== 'latent_upscale' && state.done === 0 && paused; const automatic = compatibility?.origin === 'automatic' && compatibility.level > 0; const required = Number.isInteger(compatibility?.recovery_successes_required) && compatibility.recovery_successes_required > 0 ? compatibility.recovery_successes_required : 3; @@ -583,17 +656,33 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f : t(remaining === 1 ? 'Generation is slowed automatically for stability; it speeds up after one more successful generation.' : `Generation is slowed automatically for stability; it speeds up after ${remaining} more successful generations.`, `为了稳定,生成已自动放慢;再连续完成 ${remaining} 次后会加快一些。`) : ''; - if (compatibilityNote.textContent !== compatibilityText) { - compatibilityNote.textContent = compatibilityText; - compatibilityNote.hidden = !compatibilityText; - layout(); + // All explanations share one row, including automatic compatibility. + compatibilityNote.hidden = true; + let pauseText = ''; + if (paused) { + const elapsed = waitDuration((now() - segmentStartedAt) / 1000, t); + const typical = segmentTypical(), usual = valid(typical) && typical > 0 ? waitDuration(typical, t) : ''; + const similar = overall.typical_source === 'similar'; + pauseText = t(`${elapsed} so far`, `已进行 ${elapsed}`); + if (usual) pauseText += similar ? t(` (about ${usual} on similar hardware)`, `(相近配置约 ${usual})`) + : t(` (usually about ${usual})`, `(通常约 ${usual})`); } - const level = ended ? '' : recovery ? 'retry' : firstSlow ? 'first-step' - : lowMemory ? 'memory' : memoryTight ? 'unified-memory' : compatibility && !automatic && compatibility.level > 0 ? 'compatibility' : trims.size ? 'notice' : ''; + // Only an available engine estimate can supply this long-decode fallback. + const remainingEstimate = rawRemaining(); + if (!ended && detail?.key === 'decode' && valid(remainingEstimate) && remainingEstimate >= 180) { + const minutes = Math.ceil(remainingEstimate / 60); + if (decodeMinutes === null || now() - decodeMinutesAt >= 60000 && Math.abs(minutes - decodeMinutes) >= 1) { + decodeMinutes = minutes; decodeMinutesAt = now(); + } + } else { decodeMinutes = null; decodeMinutesAt = -Infinity; } + const decodeEta = decodeMinutes === null ? '' : t(`About ${decodeMinutes} min left`, `还需约 ${decodeMinutes} 分钟`); + const progressText = [paused ? pauseText : '', decodeEta].filter(Boolean).join(t(' · ', ',')); + const level = ended ? '' : recovery ? 'retry' : paused ? 'pause' + : lowMemory ? 'memory' : memoryTight ? 'unified-memory' : automatic ? 'automatic' + : compatibility && compatibility.level > 0 ? 'compatibility' : trims.size ? 'notice' : ''; const explanation = {retry: recovery, - 'first-step': t('The first run at this size, or after an update, takes longer to start.', - '首次使用这个尺寸或更新后,开头要多花一些时间。'), - memory: t('This generation uses low-memory mode; it is slower than usual but will finish.', + pause: progressText, + memory: t('This video uses a memory-saving mode, so it takes longer than usual but will finish.', '这次生成使用省显存方式,会比平时慢,但能完成。'), // Mac, level 3 with low-memory mode (never both). The measured numbers go to the // report only. Closing applications during the first pass can still give the @@ -605,21 +694,28 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f '可用的统一内存较少,这次生成会比平时慢。'), compatibility: compatibility ? t(`Compatibility setting ${compatibility.name_en} is on, so generation is slower than usual.`, `已开启兼容性设置(${compatibility.name_zh}档),生成会比平时慢。`) : '', - notice: trimStatus([...trims.values()], t)}[level] || ''; + automatic: compatibilityText, + notice: trimStatus([...trims.values()], t)}[level] || (!ended ? progressText : ''); if (status.textContent !== explanation) status.textContent = explanation; status.hidden = !explanation; status.dataset.level = level; const announcements = []; - if (paused && !pauseAnnounced) { announcements.push(base); pauseAnnounced = true; } - for (const text of [compatibilityText, explanation !== lastExplanation ? explanation : '']) { + const pauseKey = `pause:${segmentKey}`; + if (paused && !pauseAnnounced) { + if (!announcedMessages.has(pauseKey)) announcements.push(base); + announcedMessages.add(pauseKey); pauseAnnounced = true; + } + // Seconds and local percentages are visual only. Announce the work once. + const spoken = level === 'pause' || explanation === progressText ? '' : explanation; + for (const text of [spoken !== lastExplanation ? spoken : '']) { if (!text || announcedMessages.has(text)) continue; announcements.push(text); announcedMessages.add(text); - if (announcementRecord) { - announcementRecord.messages = [...announcedMessages]; - try { sessionStorage.setItem(announcementKey, JSON.stringify(Object.fromEntries(requestAnnouncements))); } - catch { /* In-memory deduplication still works. */ } - } } - lastExplanation = explanation; + if (announcementRecord && announcements.length) { + announcementRecord.messages = [...announcedMessages]; + try { sessionStorage.setItem(announcementKey, JSON.stringify(Object.fromEntries(requestAnnouncements))); } + catch { /* Optional storage; in-memory deduplication remains. */ } + } + lastExplanation = spoken; if (announcements.length) live.textContent = announcements.join(t(' ', '')); } @@ -735,6 +831,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f recovery = ''; lowMemory = false; compatibility = null; references = false; trims.clear(); memoryTight = null; firstPassOver = false; samplingPlan = {}; secondPass = false; hasHeartbeat = false; + detail = null; localShare = null; workShares.clear(); decodeMinutes = null; decodeMinutesAt = -Infinity; announcementRecord = null; } if (!placeholderReset && (message.stream_id !== undefined || terminal)) placeholder = false; @@ -753,6 +850,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f const heartbeat = beat && previousProgress !== null; if (message.reset === true) { hasHeartbeat = false; lastActivity = null; + workShares.clear(); localShare = null; decodeMinutes = null; decodeMinutesAt = -Infinity; // A stage restarting its own count (the sampler's start) leaves the // bridge's whole-request estimate standing; a retry starts afresh. if (estimateSource && !message.retry && message.phase !== 'recovery') { completedSteps = 0; previousRemaining = null; } @@ -762,6 +860,14 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f // sampler's own start, keeps the stage's clock and the 2 s rule. if (miniFraction > 0) resetMini(); } + if (!heartbeat && message.retry && message.phase === 'recovery') { + // Resource retries do not carry reset=true. Clear only the new attempt's work. + const retryId = JSON.stringify(message.retry); + if (retryId !== JSON.stringify(state.retry)) { + workShares.clear(); localShare = null; resetMini(); + decodeMinutes = null; decodeMinutesAt = -Infinity; + } + } if (beat) hasHeartbeat = true; if (!heartbeat && message.phase === 'sampling' && valid(message.done)) completedSteps = Math.max(completedSteps, message.done); @@ -826,13 +932,24 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f enterStage(activity.key === 'download' ? `download:${state.stage || state.phase}` : activity.key, receivedAt); lastActivity = activity; } - const key = JSON.stringify([state.phase || state.timing_phase, state.stage, - state.phase === 'sampling' ? [state.done, state.total] : [state.label, lastActivity?.text]]); + detail = subactivity(state, lastActivity, t); + localShare = null; + if (['load', 'decode'].includes(detail.key) && lastActivity?.key !== 'lora') { + const shareKey = `${detail.key}:${secondPass ? 'second' : 'first'}`; + const share = hasCount(state) ? state.done / state.total + : state.label === 'Video model ready' ? 1 : null; + if (share !== null) workShares.set(shareKey, Math.max(workShares.get(shareKey) ?? 0, share)); + localShare = workShares.get(shareKey) ?? null; + } + const key = JSON.stringify([detail.key, detail.sampling ? [lastActivity?.key, state.done] : null]); if (key !== segmentKey || message.reset) { segmentKey = key; - segmentStartedAt = receivedAt - (state.phase === 'sampling' && valid(message.step_elapsed_seconds) + segmentStartedAt = receivedAt - (detail.sampling && valid(message.step_elapsed_seconds) ? message.step_elapsed_seconds * 1000 : 0); pauseAnnounced = false; + if (detail.sampling && !('estimated_step_seconds' in message)) delete state.estimated_step_seconds; + } else if (detail.sampling && valid(message.step_elapsed_seconds)) { + segmentStartedAt = Math.min(segmentStartedAt, receivedAt - message.step_elapsed_seconds * 1000); } element.dataset.phase = message.phase || message.timing_phase || ''; if (message.overall && typeof message.overall === 'object') { @@ -850,6 +967,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f if (timer !== null) clearInterval(timer); timer = null; state = {}; overall = {}; shownFraction = null; stopCount(); countShown = null; resetRemaining(); lastActivity = null; resetMini(); previousProgress = null; segmentKey = ''; status.textContent = ''; live.textContent = ''; + detail = null; localShare = null; workShares.clear(); decodeMinutes = null; decodeMinutesAt = -Infinity; // Last: the layout callback sizes the node from the cleared state. drawStall(0, true); } From 1265ff112b0a3d3e70e9dcbbf2549a453d77d6d4 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:40:07 +0000 Subject: [PATCH 42/53] Editor toolbar stays on one row at phone widths --- web/editor.css | 36 +++++++++++++++++++--- web/editor.js | 5 ++- web/editor_audio.js | 2 +- web/editor_text.css | 4 --- web/editor_toolbar_more.js | 62 ++++++++++++++++++++++++++++++++++++++ web/editor_tracks.css | 4 ++- web/editor_tracks.js | 15 +++++++++ web/health.js | 2 +- 8 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 web/editor_toolbar_more.js diff --git a/web/editor.css b/web/editor.css index 15e84942..645fb2ea 100644 --- a/web/editor.css +++ b/web/editor.css @@ -133,6 +133,20 @@ .fv-ed-toolbar>.fv-ed-zoom{margin-left:auto;flex:none} .fv-ed-toolbar .fv-ed-track-zoom{position:static;height:32px} .fv-ed-toolbar .fv-ed-zoom-slider{width:64px} +.fv-editor .fv-ed-toolbar .fv-ed-more{flex:none;margin-left:auto} +.fv-ed-more svg{width:16px;height:16px;fill:currentColor} +.fv-ed-more-panel{position:absolute;top:calc(100% - 2px);right:8px;min-width:180px;max-height:min(320px,60dvh);overflow-y:auto;padding:6px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:8px;display:flex;flex-direction:column;z-index:9} +.fv-ed-more-panel[hidden]{display:none} +.fv-ed-more-panel .fv-ed-text-buttons{display:contents} +.fv-editor .fv-ed-toolbar .fv-ed-more-panel button{flex:none;width:100%;height:32px;justify-content:flex-start;gap:10px;padding:0 10px;margin:0;color:var(--fv-ink);text-align:left;white-space:nowrap} +.fv-ed-more-panel[data-up=true]{top:auto;bottom:calc(100% - 2px)} +.fv-editor .fv-ed-toolbar .fv-ed-more-panel button:has(svg)>span{display:none} +.fv-ed-more-panel button::after{content:attr(data-more-label)} +.fv-ed-more-panel .fv-ed-text-icon{width:18px;text-align:center;flex:none} +/* A switch that is on shows it in its icon; the row tint stays for hover and focus. */ +.fv-editor .fv-ed-toolbar .fv-ed-more-panel button[aria-pressed=true]{color:var(--fv-ink);background:transparent} +.fv-editor .fv-ed-toolbar .fv-ed-more-panel button[aria-pressed=true] svg{color:var(--fv-accent)} +.fv-editor .fv-ed-toolbar .fv-ed-more-panel button:not(:disabled):hover,.fv-editor .fv-ed-toolbar .fv-ed-more-panel button:focus-visible{background:color-mix(in srgb,var(--fv-accent) 12%,transparent)} /* The tracks' basic fallback rises from the bottom; Blake supplies the waves. */ .fv-editor .fv-ed-progress-fill{width:100%;height:calc(var(--fv-ed-progress)*100%);inset:auto 0 0} .fv-ed-drawer-toggle{display:none!important} @@ -181,12 +195,24 @@ .fv-studio.fv-editor{grid-template-rows:auto minmax(96px,1fr) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,236px)} .fv-studio.fv-editor:has(.fv-ed-gen-failure:not([hidden])){grid-template-rows:auto minmax(0,1fr) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,236px)} .fv-studio.fv-editor .fv-header:has(.fv-view-switch){grid-template-columns:minmax(0,1fr) auto} - .fv-ed-toolbar{display:grid;grid-template-columns:auto minmax(0,1fr);height:auto;gap:4px 8px;padding:4px 12px;overflow:visible} - .fv-ed-toolbar>.fv-ed-tool-group:nth-child(2){grid-column:2;grid-row:1/3;flex-wrap:wrap;align-self:start;min-width:0} - .fv-ed-toolbar>.fv-ed-tool-group:nth-child(3){grid-column:1;grid-row:2} + /* One row: undo, split, delete and zoom; editor_toolbar_more.js moves the rest behind ⋯. */ + .fv-ed-toolbar{gap:4px;padding:0 12px;overflow:visible} .fv-ed-toolbar .fv-ed-tool-group+.fv-ed-tool-group:before{display:none} - .fv-ed-toolbar>.fv-ed-zoom{grid-column:1/-1;grid-row:3;justify-self:end;margin-left:0} - .fv-editor .fv-ed-track-view{--fv-ed-head-width:104px} + .fv-ed-toolbar>.fv-ed-zoom{flex:1 1 auto;min-width:0;margin-left:4px} + .fv-ed-toolbar .fv-ed-track-zoom{display:flex;align-items:center} + .fv-editor .fv-ed-toolbar .fv-ed-track-zoom>button:not(.fv-ed-fit){display:none} + .fv-ed-toolbar .fv-ed-zoom-slider{flex:1 1 auto;width:auto;min-width:48px} + /* Track heads become a 32 px icon column; a press opens that track's switches beside it. */ + .fv-studio.fv-editor .fv-ed-track-view[data-track-switches],.fv-studio.fv-editor[data-text-tracks] .fv-ed-track-view{--fv-ed-head-width:32px} + .fv-studio.fv-editor .fv-ed-track-head{justify-content:center;padding:0;cursor:pointer} + .fv-studio.fv-editor .fv-ed-track-head>svg{display:block;width:16px;height:16px} + .fv-studio.fv-editor .fv-ed-track-head[data-text-track=true]>svg{display:none} + .fv-studio.fv-editor .fv-ed-track-head[data-text-track=true]::before{content:"T";font-weight:600} + .fv-studio.fv-editor .fv-ed-track-head>[class$=-track-controls]{display:none} + .fv-studio.fv-editor .fv-ed-track-head[data-open=true]>[class$=-track-controls]{display:flex;position:absolute;left:calc(100% + 4px);top:50%;transform:translateY(-50%);gap:2px;padding:2px 4px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:8px} + .fv-studio.fv-editor .fv-ed-track-heads:has(>[data-open=true]){z-index:9} + .fv-studio.fv-editor .fv-ed-add-audio{padding:0;font-size:0} + .fv-studio.fv-editor .fv-ed-add-audio::before{content:attr(data-sign);font-size:16px} .fv-editor .fv-ed-playhead-handle{left:0} .fv-editor .fv-ed-library{grid-area:auto;top:116px;bottom:0;z-index:8} .fv-editor:has(.fv-ed-library[data-open=true]) .fv-ed-drawer-toggle{position:fixed;top:128px;right:20px;z-index:9} diff --git a/web/editor.js b/web/editor.js index 7f3211bb..0e8c8550 100644 --- a/web/editor.js +++ b/web/editor.js @@ -1,4 +1,5 @@ import {createEditorText} from './editor_text.js'; +import {createToolbarMore} from './editor_toolbar_more.js'; import {isText} from './editor_text_model.js'; import {viewSwitch, viewChanged} from './view_navigation.js'; import {wordmark} from './wordmark.js'; @@ -994,6 +995,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const transportControls = createEditorTransport({element, preview, transport, toolbar, editTools, zoomHost, state, player, t, notify}); toolbar.append(adjust.toggleButton); const textTools = createEditorText({element, state, t, actions, adjust, player, toolbar, exportBox, tracksHost}); + const toolbarMore = createToolbarMore({toolbar, t, keep: '.fv-ed-undo, .fv-ed-split, .fv-ed-delete, .fv-ed-adjust-toggle, .fv-ed-zoom'}); actions.speech.ability().then(value => { if (!disposed) textTools.speech(value); }).catch(() => {}); const audio = createAudioIntegration({state, player, t, error, adjust, library: libraryHost, store}); registerEditorShortcuts(AUDIO_SHORTCUTS, speedShortcuts); @@ -1070,6 +1072,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (tracks?.dismissPopup(event)) return true; if (pictureEdit.keyboard(event)) return true; if (adjust.dismiss(event)) return true; + if (toolbarMore.dismiss(event)) return true; if (menu.hidden) return false; event.preventDefault(); event.stopPropagation(); if (!menu.hidden) { menu.hidden = true; title.setAttribute('aria-expanded', 'false'); title.focus(); } @@ -1171,7 +1174,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given saveWarning.textContent = t('Not saved yet. Switching again will lose recent changes.', '还没保存好,再切换会丢掉最近的改动'); saveWarning.hidden = false; return false; } - rememberView(); disposed = true; guide?.dispose(); transportControls.dispose(); player.dispose(); + rememberView(); disposed = true; guide?.dispose(); transportControls.dispose(); toolbarMore.dispose(); player.dispose(); textTools.dispose(); adjust.dispose(); notes.dispose(); pictureEdit.dispose(); audio.dispose(); listeners.splice(0).forEach(remove => remove()); diff --git a/web/editor_audio.js b/web/editor_audio.js index 84454003..0a619697 100644 --- a/web/editor_audio.js +++ b/web/editor_audio.js @@ -217,7 +217,7 @@ export function attachAudioTracks(element, {state, t = en => en, actions = {}}) const lockedText = t('This track is locked', '这条轨道已锁定'); function notify() { return {ok: false, reason: 'locked'}; } function render() { - element.style.setProperty('--fv-ed-head-width', '148px'); + element.dataset.trackSwitches = 'true'; for (const track of state.record.tracks) { if (!['video', 'audio'].includes(track.kind)) continue; const head = [...element.querySelectorAll('.fv-ed-track-head')].find(node => node.dataset.track === track.id); diff --git a/web/editor_text.css b/web/editor_text.css index c9785446..04273bdb 100644 --- a/web/editor_text.css +++ b/web/editor_text.css @@ -40,7 +40,3 @@ .fv-ed-track-head[data-text-track=true]>svg{display:none} .fv-ed-text-notice{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;cursor:pointer} -@media(max-width:480px){ - .fv-editor .fv-ed-toolbar>.fv-ed-text-buttons{grid-column:1/-1;grid-row:4;justify-self:start} - .fv-editor .fv-ed-toolbar>.fv-ed-adjust-toggle{grid-column:1/-1;grid-row:4;justify-self:end;width:auto} -} diff --git a/web/editor_toolbar_more.js b/web/editor_toolbar_more.js new file mode 100644 index 00000000..2b073352 --- /dev/null +++ b/web/editor_toolbar_more.js @@ -0,0 +1,62 @@ +// At phone widths the track toolbar keeps one row; the tools outside `keep` move behind ⋯ and come back on a wider window. +const NARROW = '(max-width:480px)'; + +export function createToolbarMore({toolbar, t, keep}) { + const more = document.createElement('button'); more.type = 'button'; more.className = 'fv-ed-more fv-ed-icon'; + more.setAttribute('aria-label', t('More tools', '更多工具')); more.title = more.getAttribute('aria-label'); + more.setAttribute('aria-expanded', 'false'); more.hidden = true; + const dots = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); dots.setAttribute('viewBox', '0 0 16 16'); dots.setAttribute('aria-hidden', 'true'); + for (const x of [3, 8, 13]) { const dot = document.createElementNS(dots.namespaceURI, 'circle'); dot.setAttribute('cx', x); dot.setAttribute('cy', 8); dot.setAttribute('r', 1.3); dots.append(dot); } + more.append(dots); + const panel = document.createElement('div'); panel.className = 'fv-ed-more-panel'; panel.setAttribute('role', 'group'); panel.setAttribute('aria-label', more.title); panel.hidden = true; + toolbar.append(more, panel); + // Without matchMedia (a bare DOM) the toolbar keeps its wide layout. + const media = typeof window.matchMedia === 'function' ? window.matchMedia(NARROW) : null, moved = []; + const items = () => [...toolbar.children].flatMap(node => node.classList.contains('fv-ed-tool-group') ? [...node.children] : [node]) + .filter(node => node !== more && node !== panel && !node.matches(keep)); + // Each row names its tool; labels are read on opening because tools rename themselves (shortcut hints dropped). + function open(value) { + if (value) for (const control of panel.querySelectorAll('button')) control.dataset.moreLabel = (control.getAttribute('aria-label') || control.textContent).replace(/\s*[((][^()()]*[))]$/, ''); + panel.hidden = !value; more.setAttribute('aria-expanded', String(value)); + // Open downward when the rows fit below the toolbar, otherwise upward over the preview. + if (value) { const room = innerHeight - toolbar.getBoundingClientRect().bottom; panel.dataset.up = String(panel.offsetHeight > room - 8 && toolbar.getBoundingClientRect().top > room); } + if (value) panel.querySelector('button:not(:disabled):not([hidden])')?.focus(); + } + function apply() { + open(false); + if (media?.matches && !moved.length) { + for (const node of items()) { + const place = document.createComment('fv-ed-more'); node.before(place); moved.push([node, place]); panel.append(node); + } + } else if (!media?.matches && moved.length) { + for (const [node, place] of moved.splice(0)) { + place.replaceWith(node); + for (const control of node.matches('button') ? [node] : node.querySelectorAll('button')) delete control.dataset.moreLabel; + } + } + more.hidden = !media?.matches; + } + more.onclick = () => open(panel.hidden); + // A chosen tool acts first, then the menu closes; switches keep their pressed look for the next opening. + panel.addEventListener('click', event => { if (event.target.closest('button')) open(false); }); + // The editor routes keys itself: Escape arrives through dismiss(), Up and Down through onkeydown. + panel.onkeydown = event => { + if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return; + const controls = [...panel.querySelectorAll('button')].filter(node => !node.disabled && node.offsetParent); + if (!controls.length) return; + event.preventDefault(); event.stopPropagation(); + const at = controls.indexOf(document.activeElement), step = event.key === 'ArrowDown' ? 1 : -1; + controls[at < 0 ? 0 : (at + step + controls.length) % controls.length].focus(); + }; + const outside = event => { if (!panel.hidden && !panel.contains(event.target) && !more.contains(event.target)) open(false); }; + document.addEventListener('pointerdown', outside, true); + media?.addEventListener('change', apply); apply(); + return { + refresh: apply, + dismiss(event) { + if (panel.hidden) return false; + event.preventDefault(); event.stopPropagation(); open(false); more.focus(); return true; + }, + dispose() { media?.removeEventListener('change', apply); document.removeEventListener('pointerdown', outside, true); } + }; +} diff --git a/web/editor_tracks.css b/web/editor_tracks.css index 1082080e..7cb6be9c 100644 --- a/web/editor_tracks.css +++ b/web/editor_tracks.css @@ -1,4 +1,6 @@ .fv-ed-track-view{position:relative;box-sizing:border-box;height:236px;min-width:0;overflow:hidden;background:var(--fv-bg);color:var(--fv-ink);font:13px/1.4 var(--fv-font,system-ui);--fv-ed-head-width:104px} +/* Audio switches widen the heads; kept here because the audio stylesheet loads after the first draw. */ +.fv-ed-track-view[data-track-switches=true]{--fv-ed-head-width:148px} .fv-ed-track-view *{box-sizing:border-box} .fv-ed-track-view button,.fv-ed-track-view input{font:inherit;color:inherit} .fv-ed-track-view button{appearance:none} @@ -75,7 +77,7 @@ .fv-ed-clip-popover button+button{margin-left:6px} .fv-ed-clip-popover{max-height:calc(100% - 16px);overflow:auto} .fv-ed-track-view [hidden]{display:none!important} -@media(max-width:520px){.fv-ed-track-view{--fv-ed-head-width:32px}.fv-ed-track-head span{display:none}.fv-ed-track-zoom{right:8px}.fv-ed-clip-status{left:5px;font-size:11px}} +@media(max-width:520px){.fv-ed-track-view{--fv-ed-head-width:32px}.fv-ed-track-head>span:first-of-type{display:none}.fv-ed-track-zoom{right:8px}.fv-ed-clip-status{left:5px;font-size:11px}} @media(forced-colors:active){.fv-ed-clip,.fv-ed-selection,.fv-ed-gesture-preview{border-color:Highlight}.fv-ed-playhead,.fv-ed-snap-line{background:CanvasText}} /* Gesture affordances; track slots and heads are owned by the track layout. */ diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 72572c53..9ac08480 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -145,6 +145,17 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const heads = create('div', 'fv-ed-track-heads'); const addAudio = create('button', 'fv-ed-add-audio', t('+ Audio track', '+ 声音轨')); addAudio.type = 'button'; heads.append(addAudio); + // Narrow heads show only the track's icon; a press opens that track's switches beside it. + // Pointer down, not click: the track view captures the pointer, so the click lands on the view. + heads.addEventListener('pointerdown', event => { + if (event.button !== 0) return; + const head = event.target.closest('.fv-ed-track-head'), controls = head?.querySelector('[class$=-track-controls]'); + if (!controls || controls.contains(event.target) || (head.dataset.open !== 'true' && controls.getClientRects().length)) return; + const open = head.dataset.open !== 'true'; + for (const other of heads.querySelectorAll('.fv-ed-track-head[data-open]')) delete other.dataset.open; + if (open) head.dataset.open = 'true'; + }); + const closeHeads = event => { if (!event.target.closest?.('.fv-ed-track-head[data-open]')) for (const head of heads.querySelectorAll('.fv-ed-track-head[data-open]')) delete head.dataset.open; }; const scroll = create('div', 'fv-ed-track-scroll'); const content = create('div', 'fv-ed-track-content'); const ruler = create('div', 'fv-ed-ruler'); @@ -537,6 +548,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = heads.append(addAudio); addAudio.style.top = `${top}px`; addAudio.disabled = tracks.find(track => track.id === 'a2')?.clips.some(busy) || false; addAudio.textContent = twoAudio ? t('− Audio track', '- 声音轨') : t('+ Audio track', '+ 声音轨'); + addAudio.dataset.sign = addAudio.textContent.slice(0, 1); // Phone widths show the sign alone. addAudio.title = ''; addAudio.setAttribute('aria-label', addAudio.textContent); content.style.height = `${top - 6}px`; playhead.style.height = `${top - 24}px`; @@ -922,6 +934,8 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = if (!autoFrame) autoFrame = requestAnimationFrame(autoScroll); } function dismissPopup(event) { + const head = heads.querySelector('.fv-ed-track-head[data-open]'); + if (head) { event.preventDefault(); event.stopPropagation(); delete head.dataset.open; return true; } if (popover.hidden) return false; event.preventDefault(); event.stopPropagation(); popover.hidden = true; return true; } @@ -1122,6 +1136,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = clearSourcePreview(); draggedSource = sourcePayload(event.dataTransfer?.getData(SOURCE_TYPE)); dropPreviewKey = ''; }); + on(document, 'pointerdown', closeHeads, true); on(document, 'dragend', () => { clearSourcePreview(); draggedSource = null; dropPreviewKey = ''; showInsertion(null); showGuide(null); }); on(scroll, 'dragleave', event => { if (!scroll.contains(event.relatedTarget)) { clearSourcePreview(); dropPreviewKey = ''; showInsertion(null); showGuide(null); } }); on(zoomOut, 'click', () => zoom(state.zoom / 1.25)); on(zoomIn, 'click', () => zoom(state.zoom * 1.25)); diff --git a/web/health.js b/web/health.js index aba5c180..caaa5ce8 100644 --- a/web/health.js +++ b/web/health.js @@ -15,7 +15,7 @@ const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', - 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', + 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_terms.js', 'report_issue.js', 'studio_recent.js', 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js']; From edd14a5000834ea860b0b6ccd0f8d7d421ebbedb Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:39:01 +0000 Subject: [PATCH 43/53] Prompt pop-ups (media mentions and camera words) open under the line being typed --- web/caret_rect.js | 21 +++++++++++++++++++++ web/health.js | 2 +- web/prompt_references.js | 4 +++- web/prompt_terms.js | 4 +++- 4 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 web/caret_rect.js diff --git a/web/caret_rect.js b/web/caret_rect.js new file mode 100644 index 00000000..c3b3b901 --- /dev/null +++ b/web/caret_rect.js @@ -0,0 +1,21 @@ +// Where the caret of a textarea is on screen, for popups that should sit under the line being typed. +const COPIED = ['boxSizing', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', + 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', + 'fontVariant', 'letterSpacing', 'lineHeight', 'textTransform', 'textIndent', 'tabSize', 'wordSpacing', 'direction']; +export function caretRect(input, index = input.selectionStart) { + const box = input.getBoundingClientRect(), style = getComputedStyle(input); + const mirror = document.createElement('div'); + for (const name of COPIED) mirror.style[name] = style[name]; + Object.assign(mirror.style, {position: 'fixed', left: '-9999px', top: '0', visibility: 'hidden', + whiteSpace: 'pre-wrap', overflowWrap: 'break-word', width: `${input.clientWidth + parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth)}px`, + height: 'auto', overflow: 'hidden'}); + mirror.textContent = String(input.value).slice(0, index); + const marker = document.createElement('span'); marker.textContent = '​'; + mirror.append(marker); document.body.append(mirror); + const top = marker.offsetTop, left = marker.offsetLeft, height = marker.offsetHeight || parseFloat(style.fontSize) * 1.3; + mirror.remove(); + const border = parseFloat(style.borderTopWidth) || 0; + // The line the caret is on, clipped to the visible part of the box when the text is scrolled. + const lineTop = Math.min(Math.max(box.top + border + top - input.scrollTop, box.top), box.bottom - height); + return {left: box.left + (parseFloat(style.borderLeftWidth) || 0) + left, top: lineTop, bottom: lineTop + height}; +} diff --git a/web/health.js b/web/health.js index caaa5ce8..cb0910fa 100644 --- a/web/health.js +++ b/web/health.js @@ -13,7 +13,7 @@ const t = (en, zh) => cn ? zh : en; // launcher.js removes this flag during setup; read it before that. const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. -export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', +export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_terms.js', 'report_issue.js', 'studio_recent.js', diff --git a/web/prompt_references.js b/web/prompt_references.js index 160ec1cc..3dfb0ac4 100644 --- a/web/prompt_references.js +++ b/web/prompt_references.js @@ -1,4 +1,5 @@ // H3 numbers each reference kind separately, in the order of comfy_media.export. +import {caretRect} from './caret_rect.js'; const titles = {image: 'Picture', video: 'Video', audio: 'Audio'}; const widget = (node, name) => node?.widgets?.find(w => w.name === name); const linkFor = (node, name) => node?.graph?.links?.[node.inputs?.find(i => i.name === name)?.link]; @@ -125,7 +126,8 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a popup.style.width = `${Math.min(Math.max(rect.width, 260), innerWidth - 24)}px`; const height = Math.min(popup.scrollHeight, 280); popup.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - popup.offsetWidth - 12))}px`; - popup.style.top = `${Math.max(12, rect.bottom + height + 8 < innerHeight ? rect.bottom + 6 : rect.top - height - 6)}px`; + const line = caretRect(input); + popup.style.top = `${Math.max(12, line.bottom + height + 8 < innerHeight ? line.bottom + 4 : line.top - height - 4)}px`; } function select(index) { active = index; diff --git a/web/prompt_terms.js b/web/prompt_terms.js index d00db45e..d3f95895 100644 --- a/web/prompt_terms.js +++ b/web/prompt_terms.js @@ -1,5 +1,6 @@ // Camera and motion words offered while a prompt is typed. The list is local; nothing is sent anywhere, // a word is written only when it is chosen, and the typed language decides which form is inserted. +import {caretRect} from './caret_rect.js'; export const TERMS = [ ['dolly in', '推镜头'], ['dolly out', '拉镜头'], ['pan left', '左摇镜头'], ['pan right', '右摇镜头'], ['tilt up', '上摇镜头'], ['tilt down', '下摇镜头'], ['tracking shot', '跟拍镜头'], ['orbit shot', '环绕镜头'], @@ -70,7 +71,8 @@ export function attachTermSuggest(input, {t = en => en, host, suppressed = () => popup.style.width = `${Math.min(Math.max(rect.width * .6, 240), innerWidth - 24)}px`; const height = Math.min(popup.scrollHeight, 220); popup.style.left = `${Math.max(12, Math.min(rect.left, innerWidth - popup.offsetWidth - 12))}px`; - popup.style.top = `${Math.max(12, rect.bottom + height + 8 < innerHeight ? rect.bottom + 6 : rect.top - height - 6)}px`; + const line = caretRect(input); + popup.style.top = `${Math.max(12, line.bottom + height + 8 < innerHeight ? line.bottom + 4 : line.top - height - 4)}px`; } function select(index) { active = index; From 47715e68943ccecf748f20ea0182288c3e482043 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:41:29 +0000 Subject: [PATCH 44/53] Continued parts keep the ten-second minimum while editing --- web/editor.js | 2 +- web/editor_model.js | 77 ++++++++++++++++++++++++++++++++++---------- web/editor_tracks.js | 23 ++++++++----- 3 files changed, 76 insertions(+), 26 deletions(-) diff --git a/web/editor.js b/web/editor.js index 0e8c8550..8df633ac 100644 --- a/web/editor.js +++ b/web/editor.js @@ -1288,7 +1288,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (after.length || !clip.extend || !clip.source || store.sources.get(sourceKey(clip.source))?.missing) return true; if (!store.generationAvailable) return false; const spare = Math.min(...model.pair(state.record, clip.id).map(member => member.length - member.in - member.frames)); - tracks.extendClip(clip.id, spare + model.RULES.minSeconds * model.FPS, {silent: true}); + tracks.extendClip(clip.id, spare + model.continuedSeconds(0, capability.maxFollowSeconds) * model.FPS, {silent: true}); return true; }; await store.generationReady(); diff --git a/web/editor_model.js b/web/editor_model.js index ac2acf79..d6d007f4 100644 --- a/web/editor_model.js +++ b/web/editor_model.js @@ -6,7 +6,8 @@ import {audioClipFields, audioTrackFields, normalizeAudioEdits, splitAudioFields import {speedRange, parseSpeedData, validateSpeedPair, sourceFrame, speedEdited, trimSpeed, splitSpeed, connectSpeedOperations} from './editor_speed_model.js'; export const VERSION = 1; export const FPS = 24; -export const RULES = Object.freeze({maxSeconds: 15, singleSeconds: 30, minSeconds: 2, tailSeconds: 5, +// followSeconds mirrors chain_plan.MIN_PART_SECONDS: a part that continues another is at least this long. +export const RULES = Object.freeze({maxSeconds: 15, singleSeconds: 30, minSeconds: 2, tailSeconds: 5, followSeconds: 10, step: 17, first: 5, overlap: 22, maxTracks: 3, maxClips: 500, maxText: 8000, maxParts: 5}); // Slice and bound user text by Unicode code points, never UTF-16 halves. @@ -601,19 +602,41 @@ export const setPictureSize = (record, width, height, size_mode = 'fixed') => fi export const setPicture = pictureEditor({copy, trackBy, check, pictureFit, finish}); export const setPictureFit = (record, id, fit) => setPicture(record, id, {fit: pictureFit(fit)}); -function segmentLengths(frames, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds) { +// The shortest a part that continues another may be; a device that cannot make a part that long keeps the general minimum. +const followLeast = maxFollowSeconds => maxFollowSeconds >= RULES.followSeconds ? RULES.followSeconds : RULES.minSeconds; +// A length to continue by, in whole seconds, raised to one that parts of followSeconds..maxFollowSeconds can add up to. +export function continuedSeconds(seconds, maxFollowSeconds = RULES.maxSeconds) { + check(typeof seconds === 'number' && Number.isFinite(seconds) && typeof maxFollowSeconds === 'number' && Number.isFinite(maxFollowSeconds), 'range'); + const least = followLeast(maxFollowSeconds), follow = Math.max(least, Math.floor(Math.min(RULES.maxSeconds, maxFollowSeconds))); + const length = Math.max(least, Math.round(seconds)); + return Math.max(length, Math.ceil(length / follow) * least); +} +function segmentLengths(frames, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds, continues = false) { check(typeof maxSeconds === 'number' && Number.isFinite(maxSeconds) && maxSeconds >= RULES.minSeconds, 'range'); check(typeof maxFollowSeconds === 'number' && Number.isFinite(maxFollowSeconds) && maxFollowSeconds >= RULES.minSeconds, 'range'); const max = Math.floor(Math.min(RULES.maxSeconds, maxSeconds)) * FPS, follow = Math.floor(Math.min(RULES.maxSeconds, maxFollowSeconds)) * FPS, lengths = []; let left = Math.max(RULES.minSeconds, Math.round(frames / FPS)) * FPS; // Under half a minute is one generation wherever this device makes it in one; // half a minute or more, or beyond the device, goes in parts of a quarter minute. - if (left < RULES.singleSeconds * FPS && left <= Math.floor(maxSeconds) * FPS) return [left]; + if (!continues && left < RULES.singleSeconds * FPS && left <= Math.floor(maxSeconds) * FPS) return [left]; check(left <= RULES.maxClips * max, 'range'); - while (left) { const length = Math.min(left, lengths.length ? follow : max); lengths.push(length); left -= length; } + // Every part after the first continues the one before it and is at least followSeconds long, as chain_plan lays + // them out: the parts after the first share what is left evenly, and a rest too short for them comes off the first. + if (follow >= RULES.followSeconds * FPS) { + const seconds = continues ? continuedSeconds(left / FPS, follow / FPS) : left / FPS; + const head = continues ? 0 : Math.min(max / FPS, seconds), rest = seconds - head, count = Math.ceil(rest / (follow / FPS)); + const shared = Math.max(rest, RULES.followSeconds * count); + if (continues || seconds - shared >= RULES.minSeconds) { + check(count <= RULES.maxClips, 'range'); + const result = continues ? [] : [(seconds - shared) * FPS]; + for (let i = 0; i < count; i++) result.push((Math.floor(shared / count) + (i < shared % count ? 1 : 0)) * FPS); + return result; + } + } + while (left) { const length = Math.min(left, lengths.length || continues ? follow : max); lengths.push(length); left -= length; } const last = lengths.length - 1; if (last > 0 && lengths[last] < Math.min(follow, RULES.tailSeconds * FPS)) { - const both = lengths[last - 1] + lengths[last]; lengths[last - 1] = Math.min(last === 1 ? max : follow, Math.max(both - follow, Math.ceil(both / 2 / FPS) * FPS)); lengths[last] = both - lengths[last - 1]; + const both = lengths[last - 1] + lengths[last]; lengths[last - 1] = Math.min(last === 1 && !continues ? max : follow, Math.max(both - follow, Math.ceil(both / 2 / FPS) * FPS)); lengths[last] = both - lengths[last - 1]; } return lengths; } @@ -660,7 +683,7 @@ export function continuesBefore(record, id) { return !!video && trackBy(record, 'v1').clips.includes(video) && !!partBefore(record, video); } // The lengths one range is generated in, as planRange lays them out on empty tracks. -export const partLengths = (frames, {maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds} = {}) => segmentLengths(frames, maxSeconds, maxFollowSeconds); +export const partLengths = (frames, {maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds, continues = false} = {}) => segmentLengths(frames, maxSeconds, maxFollowSeconds, continues); // One video is generated in at most RULES.maxParts parts: the longest range that still fits. export function maxRangeFrames(record, {continues = false, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds}) { const before = continues; @@ -672,24 +695,31 @@ export function maxRangeFrames(record, {continues = false, maxSeconds = RULES.ma export function rangeParts(record, {start, frames, parts, continues, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds}) { // A range that starts on a part pulled out of the one before it keeps that part's shorter limit. const head = trackBy(record, 'v1').clips.find(clip => clip.start === start); - const first = continues ?? !!(head && partBefore(record, head)) ? maxFollowSeconds : maxSeconds; + const following = continues ?? !!(head && partBefore(record, head)), first = following ? maxFollowSeconds : maxSeconds; + // A part that continues another is at least followSeconds long. + const least = index => (index || following ? followLeast(maxFollowSeconds) : RULES.minSeconds) * FPS; const valid = Array.isArray(parts) && parts.length > 0 && parts.length <= RULES.maxClips - && parts.every((count, i) => whole(count, RULES.minSeconds * FPS) && count % FPS === 0 + && parts.every((count, i) => whole(count, least(i)) && count % FPS === 0 && count <= Math.floor(i ? maxFollowSeconds : first) * FPS) && parts.reduce((sum, count) => sum + count, 0) === frames; - return valid ? [...parts] : partLengths(frames, {maxSeconds: first, maxFollowSeconds}); + return valid ? [...parts] : partLengths(frames, {maxSeconds: first, maxFollowSeconds, continues: following}); } -export function adjustPartBoundary(parts, index, leftFrames, {maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds} = {}) { +export function adjustPartBoundary(parts, index, leftFrames, {maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds, continues = false} = {}) { check(Array.isArray(parts) && whole(index) && index < parts.length - 1 && Number.isFinite(leftFrames), 'range'); check(parts.every(count => whole(count, RULES.minSeconds * FPS) && count % FPS === 0), 'range'); const minimum = RULES.minSeconds * FPS, total = parts[index] + parts[index + 1]; const leftMax = Math.floor(index ? maxFollowSeconds : maxSeconds) * FPS, rightMax = Math.floor(maxFollowSeconds) * FPS; - const low = Math.max(minimum, total - rightMax), high = Math.min(leftMax, total - minimum); + // Every part that continues another is at least followSeconds long; a pair too short for that keeps the general minimum. + const follows = followLeast(maxFollowSeconds) * FPS, leftLeast = index || continues ? follows : minimum; + const tight = total >= leftLeast + follows; + const low = Math.max(tight ? leftLeast : minimum, total - rightMax), high = Math.min(leftMax, total - (tight ? follows : minimum)); check(Number.isFinite(low) && Number.isFinite(high) && low <= high, 'range'); const desired = Math.round(leftFrames / FPS) * FPS, left = Math.max(low, Math.min(high, desired)); const result = [...parts]; result[index] = left; result[index + 1] = total - left; + // The left part also stops where the part after it would fall under followSeconds. return {parts: result, limited: desired >= leftMax && left === leftMax ? leftMax / FPS - : total - desired >= rightMax && total - left === rightMax ? rightMax / FPS : null}; + : total - desired >= rightMax && total - left === rightMax ? rightMax / FPS + : tight && desired > left && left === total - follows ? left / FPS : null}; } export function resizePlannedBoundary(record, leftId, rightId, frame, limits = {}) { const left = find(record, leftId), right = find(record, rightId); @@ -701,7 +731,7 @@ export function resizePlannedBoundary(record, leftId, rightId, frame, limits = { const preceding = !!video && !!partBefore(record, video); const maxSeconds = limits.maxSeconds ?? RULES.maxSeconds, maxFollowSeconds = limits.maxFollowSeconds ?? maxSeconds; const result = adjustPartBoundary([left.frames, right.frames], 0, frame - left.start, - {maxSeconds: preceding ? maxFollowSeconds : maxSeconds, maxFollowSeconds}); + {maxSeconds: preceding ? maxFollowSeconds : maxSeconds, maxFollowSeconds, continues: preceding}); const next = copy(record); detachAttempt(next, members.map(clip => clip.id)); for (const old of members) find(next, old.id).part = left.part; for (const clip of pair(next, leftId)) { clip.frames = clip.length = result.parts[0]; clearPlan(clip); } @@ -712,13 +742,25 @@ export function resizePlanned(record, id, edge, frame, limits = {}) { check(['start', 'end'].includes(edge) && whole(frame), 'range'); const members = pair(record, id); check(members.length, 'missing'); busy(members); check(members.every(clip => clip.pending && !clip.source), 'unsupported'); - const primary = members[0], minimum = RULES.minSeconds * FPS; + const primary = members[0]; + const video = trackOf(record, id).id === 'v1' ? primary : find(record, primary.link); + const maxFollowSeconds = limits.maxFollowSeconds ?? limits.maxSeconds ?? RULES.maxSeconds; + // A planned part that continues another is at least followSeconds long. + const minimum = (video && partBefore(record, video) ? followLeast(maxFollowSeconds) : RULES.minSeconds) * FPS; const neighbors = members.flatMap(clip => trackOf(record, clip.id).clips.filter(other => other.id !== clip.id)); const low = Math.max(0, ...neighbors.filter(clip => end(clip) <= primary.start).map(end)); const high = Math.min(1e7, ...neighbors.filter(clip => clip.start >= end(primary)).map(clip => clip.start)); const position = Math.round(frame / FPS) * FPS; - const start = edge === 'start' ? Math.max(low, Math.min(end(primary) - minimum, position)) : primary.start; - const stop = edge === 'end' ? Math.min(high, Math.max(start + minimum, position)) : end(primary); + let start = edge === 'start' ? Math.max(low, Math.min(end(primary) - minimum, position)) : primary.start; + let stop = edge === 'end' ? Math.min(high, Math.max(start + minimum, position)) : end(primary); + if (minimum > RULES.minSeconds * FPS) { + // Lengths no run of continuing parts can fill (16–19 s at fifteen-second parts) are raised to the next one that fits. + const fitted = continuedSeconds((stop - start) / FPS, maxFollowSeconds) * FPS; + if (fitted > stop - start) { + if (edge === 'end' && start + fitted <= high) stop = start + fitted; + else if (edge === 'start' && stop - fitted >= low) start = stop - fitted; + } + } let next = copy(record); detachAttempt(next, members.map(clip => clip.id)); const part = primary.part || freshId(next); for (const clip of pair(next, id)) { clip.start = start; clip.frames = clip.length = stop - start; clip.part = part; clearPlan(clip); } @@ -848,7 +890,8 @@ export function extendClip(record, id, frames, {maxSeconds = RULES.maxSeconds, m const spare = Math.min(...selected.map(member => member.length - member.in - member.frames)); const reveal = Math.min(spare, frames); if (frames <= spare) return trim(record, id, 'end', end(clip) + reveal); - const count = Math.max(RULES.minSeconds, Math.round((frames - spare) / FPS)) * FPS; + // Parts that continue this one are at least followSeconds long, so what is added is raised to a length they can fill. + const count = continuedSeconds((frames - spare) / FPS, maxFollowSeconds) * FPS; // One shift measured from the original visible edge, including restored media. const room = copy(record); makeRightEdgeRoom(room, id, reveal + count, {generating: true}); const restored = trim(finish(room), id, 'end', end(clip) + reveal); diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 9ac08480..4b7f08a8 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -75,14 +75,15 @@ export function rangeMoveParameters(record, range, anchor, frame, points, zoom, // Classification uses the source tail only to decide which gesture to preview. // trim/extendClip still validate the requested edit (including pairs and limits). -export function edgeParameters(clip, edge, frame, points, zoom, {canExtend = true} = {}) { +export function edgeParameters(clip, edge, frame, points, zoom, {canExtend = true, maxFollowSeconds = model.RULES.maxSeconds} = {}) { const snapped = snapFrame(frame, points, zoom); if (clip.pending && !clip.source) return {kind: 'planned', edge, frame: snapped.frame, guide: snapped.guide}; const sourceEnd = clip.start + clip.length - clip.in; if (speedEdited(clip)) return {kind: 'trim', edge, frame: snapped.frame, guide: snapped.guide}; if (edge === 'end' && canExtend && clip.extend && snapped.frame > sourceEnd) { - const extra = Math.max(2 * model.FPS, Math.round((snapped.frame - sourceEnd) / model.FPS) * model.FPS); - return {kind: 'extend', frame: sourceEnd, frames: extra, guide: snapped.guide}; + // What continues a video is at least followSeconds long; the preview already shows the raised length. + const wanted = Math.round((snapped.frame - sourceEnd) / model.FPS), seconds = model.continuedSeconds(wanted, maxFollowSeconds); + return {kind: 'extend', frame: sourceEnd, frames: seconds * model.FPS, guide: snapped.guide, raised: seconds !== wanted}; } return {kind: 'trim', edge, frame: snapped.frame, guide: snapped.guide}; } @@ -798,7 +799,8 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = Object.assign(gesture, {type: 'range', anchor: clipEnd, flush: clipEnd, id: undefined, ids: [], clip: null, edge: null, rightPreviewIds: []}); cachedPoints = allPoints([]); tooltip.hidden = true; updatePointer(event); return; } - let result = edgeParameters(gesture.clip, gesture.edge, frame, points, state.zoom, {canExtend: actions.generationAvailable?.() !== false}); + let result = edgeParameters(gesture.clip, gesture.edge, frame, points, state.zoom, + {canExtend: actions.generationAvailable?.() !== false, maxFollowSeconds: boundaryLimits().maxFollowSeconds}); if (result.kind === 'extend') result.frames = capRange({start: result.frame, frames: result.frames}, false, true).frames; if (result.kind === 'planned') { let preview; @@ -832,10 +834,11 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const room = model.rightEdgeRoom(state.record, gesture.id, Math.max(0, growth), {generating: result.kind === 'extend'}); if (!room.blocked) return; blocked = true; - result = edgeParameters(gesture.clip, 'end', room.limit, [], state.zoom); + result = edgeParameters(gesture.clip, 'end', room.limit, [], state.zoom, {maxFollowSeconds: boundaryLimits().maxFollowSeconds}); if (result.kind === 'extend') { result.frames = Math.floor((room.limit - result.frame) / model.FPS) * model.FPS; - if (result.frames < model.RULES.minSeconds * model.FPS) result = {kind: 'trim', edge: 'end', frame: result.frame, guide: null}; + // Less room than a continued part needs: the edge only trims. + if (result.frames < model.continuedSeconds(0, boundaryLimits().maxFollowSeconds) * model.FPS) result = {kind: 'trim', edge: 'end', frame: result.frame, guide: null}; } preview = edit(result); } @@ -876,7 +879,9 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = + (result.kind === 'extend' ? result.frames : 0); if (gesture.edge === 'end' && (growth > 0 || blocked)) { clearTimeout(estimateTimer); estimateToken++; - tooltip.textContent = blocked ? '' : t(`+${Math.round(growth / model.FPS)} s`, `+${Math.round(growth / model.FPS)} 秒`); + // A length below the shortest continued part shows that rule; release sets it to that length. + tooltip.textContent = blocked ? '' : result.raised ? t(`At least ${result.frames / model.FPS} s`, `至少 ${result.frames / model.FPS} 秒`) + : t(`+${Math.round(growth / model.FPS)} s`, `+${Math.round(growth / model.FPS)} 秒`); tooltip.hidden = !!blocked; selectionBox.classList.toggle('fv-ed-cannot-drop', !!blocked); } @@ -903,7 +908,9 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = else if (result.kind === 'planned') attempt(t('Resize', '改长短'), record => model.resizePlanned(record, finished.id, finished.edge, result.frame, actions.generationLimits?.())); else if (result.kind === 'extend') { const frames = result.frame - finished.clip.start - finished.clip.frames + result.frames; - extendClip(finished.id, frames); + if (extendClip(finished.id, frames) && result.raised) { + actions.notify?.(t(`Extended to ${result.frames / model.FPS} s.`, `已补到 ${result.frames / model.FPS} 秒。`)); + } } else attempt(t('Trim', '裁短'), record => model.trim(record, finished.id, finished.edge, result.frame)); renderSelection(); } finally { actions.gestureEnd?.(); } From b35c7d0a314d7746c028ec0b19d84c056c5e1c68 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:54:48 +0000 Subject: [PATCH 45/53] Agent films: screenplay errors name the field; unknown shot and line fields are reported --- freevideo_engine/agent/production.py | 43 ++++++++++++++++++++++++--- freevideo_engine/agent/productions.py | 10 +++++-- freevideo_engine/agent/schema.py | 10 ++++++- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/freevideo_engine/agent/production.py b/freevideo_engine/agent/production.py index 9faf0a22..07a5c72e 100644 --- a/freevideo_engine/agent/production.py +++ b/freevideo_engine/agent/production.py @@ -71,8 +71,40 @@ def _compiled(play, shot): return drama.prompt(play, shot) -def plan(request): - """Return {'request': normalized copy, 'preview': {'shots': [...], 'total': N}}.""" +# What a screenplay shot and a line may say; anything else would be ignored without a word. +SHOT_FIELDS = ('id', 'seconds', 'action', 'idea', 'prompt', 'camera', 'framing', 'sound', 'music', 'takes', 'seed', + 'continue', 'first', 'references', 'cast', 'lines') +LINE_FIELDS = ('who', 'line', 'how', 'language') + + +def _rule(row): + """A screenplay problem as a rule about its field; the pointer already travels in path. + + A leading pointer in the text would read as a local path once paths are removed.""" + if isinstance(row.get('allowed'), str): + rule = row['allowed'] + return rule if rule.startswith('must') else 'must be ' + rule + return re.sub(r'^/\S*\s+', '', row['message']) if isinstance(row.get('message'), str) else 'is not valid' + + +def _fields(play, problems): + for i, shot in enumerate(play.get('shots') or [] if isinstance(play, dict) else []): + if not isinstance(shot, dict): + continue + rows = [('/screenplay/shots/%d/' % i, key, SHOT_FIELDS) for key in shot if key not in SHOT_FIELDS] + for j, line in enumerate(shot.get('lines') if isinstance(shot.get('lines'), list) else []): + if isinstance(line, dict): + rows += [('/screenplay/shots/%d/lines/%d/' % (i, j), key, LINE_FIELDS) + for key in line if key not in LINE_FIELDS] + for prefix, key, known in rows: + name = str(key).replace('~', '~0').replace('/', '~1') + problems.append(dict(path=prefix + name, message='is not a field here; use ' + ', '.join(known))) + + +def plan(request, *, fields=True): + """Return {'request': normalized copy, 'preview': {'shots': [...], 'total': N}}. + + fields=False rechecks a saved screenplay, which may predate the unknown-field rule.""" problems = [] if not isinstance(request, dict): _invalid([dict(path='', message='A production request must be an object')]) @@ -83,7 +115,9 @@ def plan(request): drama.check(play) except drama.Screenplay as error: play_ok = False - problems.extend(dict(path='/screenplay' + p['path'], message=p['message']) for p in error.problems) + problems.extend(dict(path='/screenplay' + p['path'], message=_rule(p)) for p in error.problems) + if fields: + _fields(play, problems) count = request.get('takes_per_shot', 1) count_ok = _count(count, '/takes_per_shot', problems) director = request.get('director', 'agent') @@ -548,7 +582,8 @@ def reshoot(state, shot, *, by, note=None, changes=None, takes=1, reason=None, n source = draft['screenplay']['shots'][row['index'] - 1] source.update(deepcopy(changes or {})) # Recheck the complete effective screenplay, transactionally. - plan(dict(screenplay=draft['screenplay'], takes_per_shot=draft['takes_per_shot'], director=draft['director'])) + plan(dict(screenplay=draft['screenplay'], takes_per_shot=draft['takes_per_shot'], director=draft['director']), + fields=False) _schedule(draft, row, takes, by, note) draft['cancelled'], draft['current_join'] = False, None _decision(draft, now, by, 'reshoot', shot, reason=reason) diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 8d21f098..080d6823 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -84,8 +84,12 @@ def __init__(self, error): super().__init__(error.code, error.message) self.status = errors.PRODUCTION_STATUS.get(error.code, errors.ERRORS[error.code][0]) message = production._public(error.message) if isinstance(error.message, str) else '' - self.error = errors.build(error.code, validation=True, message=message[:300], - details=_rows(error), next_action=(FILM_STOPPED_NEXT if error.code == 'stopped_by_user' else error.next)) + rows = _rows(error) + if error.code == 'invalid_screenplay' and rows and all('field' in row for row in rows): + # Like a request validation error: every problem with its field, as the tool text shows only this. + message = '; '.join(row['message'] for row in rows)[:2000] + self.error = errors.build(error.code, validation=True, message=message[:2000], + details=rows, next_action=(FILM_STOPPED_NEXT if error.code == 'stopped_by_user' else error.next)) if error.code == 'not_ready': self.error['retryable'] = False @@ -172,7 +176,7 @@ def load(self): raise ValueError('Invalid submission marker') production.view(state) try: - checked = production.plan(request) + checked = production.plan(request, fields=False) if [s['id'] for s in state['shots']] != [s['id'] for s in checked['preview']['shots']]: raise JobError('internal_error', 'Saved shots differ from the current plan') production.advance(deepcopy(state), deepcopy(record['children']), now=state['updated_at']) diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index a4e93ac1..af3fc044 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -427,9 +427,17 @@ def route(summary, body=None, query=None, **kw): } +# The fields are checked when planning; the shape is told here so an agent does not guess it. +SCREENPLAY = {'type': 'object', 'description': ( + 'characters: {key: {card: cast card ID, or name, look, voice, language}}; ' + 'shots: [{id, seconds, action or prompt, camera, framing, sound, takes, seed, ' + 'cast: [character keys], lines: [{who: character key, line, how}], ' + 'continue: true to carry on from the previous shot}]; also title, style, aspect, quality, music.')} + + def drama_schema(name): if name in ('plan', 'start'): - return obj(dict(screenplay={'type': 'object'}, takes_per_shot=number(1, 8, integer=True, default=1), + return obj(dict(screenplay=SCREENPLAY, takes_per_shot=number(1, 8, integer=True, default=1), director=enum(('agent', 'builtin'), default='agent'), ref=REF), ('screenplay',)) if name == 'list': return obj(dict(status=enum(('active', 'all'), default='all'), limit=number(1, 200, integer=True, default=50))) From 53e5b86ac46db6886dc5cf99631e59bae0310f16 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 12:54:49 +0000 Subject: [PATCH 46/53] Agent films: take sheets show one frame per second by default --- freevideo_engine/agent/builtin_director.py | 2 +- freevideo_engine/agent/productions.py | 20 ++++++++++++++------ freevideo_engine/agent/schema.py | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/freevideo_engine/agent/builtin_director.py b/freevideo_engine/agent/builtin_director.py index 76261b05..6b280055 100644 --- a/freevideo_engine/agent/builtin_director.py +++ b/freevideo_engine/agent/builtin_director.py @@ -501,7 +501,7 @@ def run(tools, production_id, *, ask, stop, wait=25): fingerprint = _ref(production_id, 'review', shot) if fingerprint in handed_off or shot['chosen'] is not None or shot['chosen_by'] == 'user': continue - sheets = tools.sheet(production_id, shot['id'], frames=6) + sheets = tools.sheet(production_id, shot['id'], frames=None) if stop(): return decision = judge(shot, sheets, ask=ask) diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 080d6823..54c794f2 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -25,6 +25,8 @@ from .jobs import DONE, JobError, iso, parts_problems from .forecast import generation_estimate +SHEET_LEAST, SHEET_MOST = 6, 24 # frames per take when the caller gives no count: one per second + MAX_GENERATION = 4 # Generations are zero-based: original 0, reshoots 1 through 4. MAX_CHILDREN = 60 # Includes assembly, rejected submissions and resumed attempts. MAX_WAITERS = 32 # Long GET waits served at once; more callers receive the current state. @@ -924,25 +926,31 @@ def _current(self, play): if isinstance(p, dict) and isinstance(p.get('card'), str)} return {i: cast.snapshot(i) if cast is not None else None for i in identities} - def sheet(self, identity, shot, *, frames=6): - """Return bytes in process; HTTP converts these to inline image content.""" + def sheet(self, identity, shot, *, frames=None): + """Return bytes in process; HTTP converts these to inline image content. + + Without a count each take shows one frame per second: H3 may caption a spoken line for only a + second or so, which a sparser sheet steps over. + """ from . import media - schema.validate(schema.drama_schema('sheet'), dict(frames=frames)) + if frames is not None: + schema.validate(schema.drama_schema('sheet'), dict(frames=frames)) with self.changed: record = self._record(identity) row = _domain(production._shot, record['state'], shot) - takes = [(t['take'], t['library_id']) for t in row['takes'] + takes = [(t['take'], t['library_id'], t.get('seconds')) for t in row['takes'] if t['state'] == 'succeeded' and not t['stale']] parent = production._parent(record['state'], row) previous = parent['library_id'] if parent else None result = dict(takes=[], previous=None) self.directory.mkdir(parents=True, exist_ok=True) with self.jobs.cpu, tempfile.TemporaryDirectory(dir=self.directory) as folder: - for number, library in takes: + for number, library, seconds in takes: if not library: raise JobError('no_video', 'Take has no saved video') output = Path(folder) / ('take-%d.jpg' % number) # media never overwrites a finished file - media.contact_sheet(self.jobs.port.library_video(library), output, count=frames, columns=min(4, frames)) + count = frames or min(SHEET_MOST, max(SHEET_LEAST, math.ceil(seconds or 0))) + media.contact_sheet(self.jobs.port.library_video(library), output, count=count, columns=min(4, count)) result['takes'].append(dict(take=number, image=output.read_bytes())) if previous: output = Path(folder) / 'previous.png' diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index af3fc044..11ef962f 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -444,7 +444,7 @@ def drama_schema(name): if name == 'get': return obj(dict(since=number(1, integer=True), wait=WAIT)) if name == 'sheet': - return obj(dict(frames=number(2, 24, integer=True, default=6))) + return obj(dict(frames=number(2, 24, integer=True, description='Frames per take; default one per second of the take (6 to 24)'))) if name == 'choose': return obj(dict(take=number(1, integer=True), reason=string(20000), cascade={'type': 'boolean'}, ref=REF), ('take',)) if name == 'reshoot': From 67a02f1f9acb431274e0f6305a8596c1493bb418 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 13:08:43 +0000 Subject: [PATCH 47/53] Editor: queued parts keep their label after a reload; an edit opens fitted to its content --- web/editor.js | 8 ++++++-- web/editor_model.js | 5 +++++ web/editor_tracks.js | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/editor.js b/web/editor.js index 8df633ac..10305622 100644 --- a/web/editor.js +++ b/web/editor.js @@ -375,7 +375,11 @@ export function openEditor({api, app, from = null, panel = {}, components: given const found = described.filter(clip => clip.start < start).at(-1) || described.at(-1); return found ? {text: found.text, before: false} : null; } - const markHeld = (ids, queued) => { for (const id of ids) tracks?.setProgress(id, {state: 'waiting', fraction: 0, remainingSeconds: null, ...(queued ? {queued: true} : {})}); }; + // Kept in the progress map too: the tracks may not exist yet when a reopened edit restores its queued parts. + const markHeld = (ids, queued) => { for (const id of ids) { + const value = {state: 'waiting', fraction: 0, remainingSeconds: null, ...(queued ? {queued: true} : {})}; + progress.set(id, value); tracks?.setProgress(id, value); + } }; const queuedReady = () => t('Queued. It starts once generation is ready.', '已排队,准备好后自动开始。'); function hold(selection, text, job = null) { const range = selectionRange(selection), selectedIds = selectionClips(selection).map(clip => clip.id), shown = selection === state.selection; @@ -555,7 +559,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given player.pause(); for (const stop of jobWatchers.values()) stop(); jobWatchers.clear(); jobStatuses.clear(); progress.clear(); runProgress.clear(); failures.clear(); progressConnection?.reset(); mostRecentJob = null; submissionFailure = null; genbar?.update(null); queue.reset(record); stopExport?.(); stopExport = null; exportStatus = exportSnapshot = null; exportButton.textContent = t('Export', '导出'); suppressSave = true; try { state.replace(record); savedContent = content(record); } finally { suppressSave = false; } - const width = tracksHost.clientWidth - 148; if (width > 0) state.setZoom(width / 90); + const width = tracksHost.clientWidth - 148; if (width > 0) state.setZoom(model.openingZoom(width, record)); tracks?.setScrollLeft(0); // An empty edit opens with one part's length selected at the start, ready to generate. const selectInitial = () => { if (!store.generationAvailable) return; initialSelection = state.select({kind: 'range', start: 0, diff --git a/web/editor_model.js b/web/editor_model.js index d6d007f4..7587ebd9 100644 --- a/web/editor_model.js +++ b/web/editor_model.js @@ -223,6 +223,11 @@ export function serialize(record) { return value; } export const duration = record => Math.max(0, ...all(record).map(end)); +// Pixels per second when an edit opens: what is there plus ten seconds of room, between twenty and ninety seconds wide. +export const openingZoom = (width, record) => { + const frames = duration(record), seconds = frames ? Math.min(90, Math.max(20, frames / FPS + 10)) : 90; + return width / seconds; +}; // An export runs as long as the picture and sound; text after them is not exported. export const exportFrames = record => Math.max(0, ...record.tracks.filter(track => ['video', 'audio'].includes(track.kind)).flatMap(track => track.clips).map(end)); export const find = (record, id) => all(record).find(clip => clip.id === id) ?? null; diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 4b7f08a8..3b202b9a 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -392,7 +392,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = // Defer the initial 90-second view until a detached shell is mounted. if (!mountedStates.has(state) && scroll.clientWidth) { mountedStates.add(state); - if (state.zoom === 12) state.setZoom(scroll.clientWidth / 90); + if (state.zoom === 12) state.setZoom(model.openingZoom(scroll.clientWidth, state.record)); } const duration = Math.max(model.duration(state.record), state.selection?.kind === 'range' ? state.selection.start + state.selection.frames : 0); contentWidth = Math.max(scroll.clientWidth, pixels(duration + 30 * model.FPS)); From 45be456239805e8fd1812d3f127649606f728498 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 13:09:11 +0000 Subject: [PATCH 48/53] Editor: the generation bar keeps its layout while a part is made; the wait line clears when it ends --- web/editor.css | 2 ++ web/editor.js | 2 +- web/editor_genbar.js | 4 +++- web/editor_references.js | 9 ++++++--- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/web/editor.css b/web/editor.css index 645fb2ea..89de825c 100644 --- a/web/editor.css +++ b/web/editor.css @@ -69,6 +69,8 @@ .fv-ed-gen-stage{flex:0 1000 auto} .fv-ed-gen-time{flex:none;max-width:100%} .fv-ed-genbar[data-mode=selected] .fv-ed-gen-hint{flex:none} +.fv-ed-genbar[data-mode=running] .fv-ed-gen-hint{order:1;flex:none} +.fv-ed-genbar[data-mode=running]>button:not(.fv-ed-generate){order:2} .fv-ed-gen-note{font-size:13px;color:var(--fv-ink);flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-ed-gen-note[hidden]{display:none} .fv-editor:has(.fv-ed-gen-failure:not([hidden])){--fv-ed-generation-row:max-content} diff --git a/web/editor.js b/web/editor.js index 10305622..d5a44d05 100644 --- a/web/editor.js +++ b/web/editor.js @@ -241,7 +241,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given return true; } const content = record => JSON.stringify({...record, id: null, revision: 0}); - function notify(text) { if (disposed) return; notice.textContent = text; notice.hidden = false; clearTimeout(noticeTimer); noticeTimer = setTimeout(() => { notice.hidden = true; }, 9000); } + function notify(text) { if (disposed) return; if (!text) { clearTimeout(noticeTimer); notice.hidden = true; return; } notice.textContent = text; notice.hidden = false; clearTimeout(noticeTimer); noticeTimer = setTimeout(() => { notice.hidden = true; }, 9000); } // `step` names what was being done, so a failure there says which step went wrong. function error(value, step = null) { const code = value?.reason || value?.code || value?.message; diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 137a2cf7..2e2c459e 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -74,7 +74,9 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { const opening = (starting || !!actions.selectionSending?.()) && !held; const stopped = ['failed', 'cancelled', 'interrupted'].includes(status?.state); host.dataset.mode = busy ? 'running' : selected ? 'selected' : 'idle'; - hint.hidden = !busy && !other; input.hidden = generate.hidden = busy; + hint.hidden = !busy && !other; + // The description and Generate stay where they are while a part is made, greyed, so nothing around them moves. + input.hidden = generate.hidden = false; // A choice that will move to an empty spot can still be described first. const writable = selected || !previewSelection && !!selection && !!actions.selectionMovable?.(); input.disabled = !writable || busy; diff --git a/web/editor_references.js b/web/editor_references.js index e085128f..5f2f8ec3 100644 --- a/web/editor_references.js +++ b/web/editor_references.js @@ -12,7 +12,7 @@ export function createEditorReferences({t, source, size = () => null, error = () const menu = el('div', 'fv-ed-ref-menu'); menu.hidden = true; menu.setAttribute('role', 'menu'); const picker = el('input'); picker.type = 'file'; picker.accept = 'image/*'; picker.multiple = true; picker.hidden = true; root.append(add, chips, menu, picker); - let shown = false, busy = false, working = false, opener = null, disposed = false, entries = []; + let shown = false, busy = false, working = false, opener = null, disposed = false, entries = [], waited = false; const active = () => source.rows().map((row, index) => ({row, index})).filter(({row}) => row.enabled !== false); const isImage = file => IMAGE.test(file || ''); // On hover without a full stop; in the notice line with one. @@ -109,7 +109,10 @@ export function createEditorReferences({t, source, size = () => null, error = () if (rows[entry.index]?.file === entry.row.file) { rows[entry.index] = {...rows[entry.index], enabled: false}; write(rows); } })]; } + // The line said on a click while a part is made; it goes away with the busy state it describes. + const wait = () => { waited = true; notify(sentence(waitText())); }; function update(isBusy = busy, visible = shown) { + if (waited && busy && !isBusy) { waited = false; notify(''); } busy = isBusy; shown = visible; root.hidden = !shown || !source.available(); if ((root.hidden || busy) && !menu.hidden) close(false); @@ -125,12 +128,12 @@ export function createEditorReferences({t, source, size = () => null, error = () chip.title = busy ? waitText() : label(entry.row.role); chip.setAttribute('aria-haspopup', 'menu'); chip.setAttribute('aria-label', label(entry.row.role)); chip.append(thumb(entry.row.file, 'fv-ed-ref-thumb')); - chip.onclick = () => { if (busy) { notify(sentence(waitText())); return; } if (!menu.hidden && opener === chip) close(); else open(chip, chipMenu(entry)); }; + chip.onclick = () => { if (busy) { wait(); return; } if (!menu.hidden && opener === chip) close(); else open(chip, chipMenu(entry)); }; return chip; })); } add.onclick = () => { - if (busy) { notify(sentence(waitText())); return; } + if (busy) { wait(); return; } if (active().length >= MAX) { notify(sentence(fullText())); return; } if (!menu.hidden && opener === add) close(); else open(add, addMenu()); }; picker.onchange = () => addFiles(Array.from(picker.files)); From 0bf2d8e2bbefbb98ff15d91d0f0b2d1c92828311 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 13:52:36 +0000 Subject: [PATCH 49/53] AI autopilot: Settings stays in the header on narrow windows; the connection shows as a dot --- web/agent_connect.css | 2 +- web/autopilot.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/agent_connect.css b/web/agent_connect.css index 3a62b07c..2d0c8ca2 100644 --- a/web/agent_connect.css +++ b/web/agent_connect.css @@ -34,5 +34,5 @@ .fv-autopilot-line[data-on=false]{opacity:0} @media(max-width:1050px){.fv-autopilot-now-go>em{display:none}.fv-autopilot-now-name{max-width:120px}} @media(max-width:820px){.fv-studio.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])){height:auto;min-height:98px;grid-template-columns:auto minmax(0,1fr);padding-block:10px}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{grid-column:2;grid-row:1}} -@media(max-width:480px){.fv-studio.fv-autopilot .fv-header{grid-template-columns:auto minmax(0,1fr)}.fv-autopilot .fv-header-actions>.fv-quiet{display:none}.fv-autopilot-now{font-size:12.5px}.fv-autopilot-now-name{max-width:92px}.fv-studio .fv-autopilot-now-go{padding:5px 2px 5px 10px}.fv-studio .fv-autopilot-lamp{padding:4px 10px;font-size:12.5px}.fv-autopilot-now:not([hidden])+.fv-autopilot-lamp[data-color=green]>span{display:none}.fv-agent-connect-body{padding:22px}} +@media(max-width:480px){.fv-studio.fv-autopilot .fv-header{grid-template-columns:auto minmax(0,1fr)}.fv-autopilot-now{font-size:12.5px}.fv-autopilot-now-name{display:none}.fv-autopilot .fv-header-actions>.fv-quiet{flex:none;white-space:nowrap}.fv-studio .fv-autopilot-now-go{padding:5px 2px 5px 10px}.fv-studio .fv-autopilot-lamp{width:30px;height:30px;padding:0;justify-content:center}.fv-autopilot-lamp>span{display:none}.fv-agent-connect-body{padding:22px}} @media(prefers-reduced-motion:reduce){.fv-autopilot-line{transition:none}} diff --git a/web/autopilot.js b/web/autopilot.js index b4f20b77..fe348b62 100644 --- a/web/autopilot.js +++ b/web/autopilot.js @@ -64,7 +64,7 @@ export function openAutopilotView(node, {studio = null} = {}) { body.dataset.view = talking ? 'chat' : 'connect'; if (talking) chat.sync(); if (arrived) chat.input.focus(); - lamp.hidden = !ready; lamp.dataset.color = label.color; lampText.textContent = label.text; lamp.disabled = connected || !last.any; + lamp.hidden = !ready; lamp.dataset.color = label.color; lampText.textContent = lamp.title = label.text; lamp.setAttribute('aria-label', label.text); lamp.disabled = connected || !last.any; const job = talking ? last.current : null; now.hidden = !job; line.dataset.on = String(!!job); halt.disabled = !!last.stopping; From b5565fa1f85291d9b13e0179ccd22bef296e9216 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 14:11:53 +0000 Subject: [PATCH 50/53] Editor: a too-long or too-short extension shows its reason once, without a report button --- web/editor.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/editor.js b/web/editor.js index d5a44d05..084e7aa4 100644 --- a/web/editor.js +++ b/web/editor.js @@ -797,8 +797,11 @@ export function openEditor({api, app, from = null, panel = {}, components: given } catch (value) { if (epoch === projectEpoch) { if (ids.length) state.settle(record => model.settle(record, ids, 'draft')); - failures.submission(value, frozen, text); - submissionFailure = JSON.stringify(state.selection); genbar?.update(null); + // A length limit is the user's to change and is said once in the notice; there is nothing to report. + if (!['chain_too_long', 'chain_part_too_short'].includes(value?.code)) { + failures.submission(value, frozen, text); submissionFailure = JSON.stringify(state.selection); + } + genbar?.update(null); } throw value; } finally { sendingRanges.delete(range); for (const id of ids) sending.delete(id); if (disposed && ids.length) queue.enqueue(state.record); generationBusy = false; finishGeneration?.(); generationDone = null; finishGeneration = null; updateTop(); updateTransport(); startHeld(); } From 78c046ff5c954f1afe6de09ad6e87f2c6a97790b Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 14:26:35 +0000 Subject: [PATCH 51/53] Settings: the model-folder box is taller and its status sits beside the save button --- web/setup.css | 5 +++++ web/setup.js | 28 +++++++++++++++++----------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/web/setup.css b/web/setup.css index 06cf4bfe..64a0f18d 100644 --- a/web/setup.css +++ b/web/setup.css @@ -81,4 +81,9 @@ .fv-setup table.fv-speed td.fv-off{color:var(--fv-disabled)} .fv-setup .fv-level{margin:4px 0 0;text-align:center;font-weight:var(--fv-semibold);color:var(--fv-accent)} .fv-setup .fv-failure{color:var(--fv-danger)}.fv-setup .fv-ready{color:var(--fv-success)} +.fv-setup .fv-warn{color:var(--fv-warning)} +.fv-setup .fv-folder-save{display:flex;flex-wrap:wrap;align-items:flex-start;gap:4px 12px;margin-top:12px} +.fv-setup .fv-folder-save button{flex:none} +.fv-setup .fv-folder-save .fv-folder-path{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.fv-setup .fv-folder-save p{flex:1 1 240px;min-width:0;margin:0;padding-top:7px;line-height:1.6} @media(max-width:480px){.fv-setup{padding:0 16px 18px}.fv-setup-head{margin:0 -16px 8px;padding:14px 12px 12px 16px}.fv-setup-head .fv-wordmark{width:92px}.fv-setup>details,.fv-setup>section{padding:14px}} diff --git a/web/setup.js b/web/setup.js index e541cfc4..0093e327 100644 --- a/web/setup.js +++ b/web/setup.js @@ -49,7 +49,10 @@ export async function openSetup() { const rootLabel = node("label", t("FreeVideo installation folder", "FreeVideo 安装目录")); const root = node("input"); root.type = "text"; rootLabel.append(root); inputs.append(rootLabel); const extraLabel = node("label", t("Additional model folders (optional, one per line)", "其他模型目录(可选,每行一个)")); - const extra = node("textarea"); extra.rows = 2; extraLabel.append(extra); inputs.append(extraLabel); + const extra = node("textarea"); extra.rows = 4; extraLabel.append(extra); inputs.append(extraLabel); + const saveFolders = node('button', t('Save model folders', '保存模型目录')); + const folderStatus = node('p'); folderStatus.setAttribute('role', 'status'); + const folderRow = node('div'); folderRow.className = 'fv-folder-save'; folderRow.append(saveFolders, folderStatus); inputs.append(folderRow); const libraries = node("details"); libraries.append(node("summary", t("Automatically detected model folders", "自动发现的模型目录"))); const paths = node("pre"); libraries.append(paths); inputs.append(libraries); const copyLabel = node("label"); copyLabel.className = 'fv-check'; const copy = node("input"); copy.type = "checkbox"; @@ -145,8 +148,7 @@ export async function openSetup() { const use = node("button", t("Use existing", "使用已有安装")); const install = node("button", t("Install / repair", "安装/修复")); install.disabled = true; const cancel = node("button", t("Stop setup", "停止安装")); cancel.hidden = true; - const saveFolders = node('button', t('Save model folders', '保存模型目录')); - actions.append(saveFolders, inspect, use, install, cancel); installation.append(actions); + actions.append(inspect, use, install, cancel); installation.append(actions); // Why Install / repair is unavailable, so it never looks like a broken button. const blocked = note(""); blocked.classList.add("fv-setup-why"); blocked.setAttribute("aria-live", "polite"); blocked.hidden = true; installation.append(blocked); @@ -160,7 +162,7 @@ export async function openSetup() { document.body.append(dialog); dialog.showModal(); let savedFolders = "", savedMissing = false; let token, planId, busy = false, probeBusy = false, pollTimer, lastPlanId, planErrors = false, planStale = false, ready = false; - const fail = error => { installation.open = true; state.textContent = [savedFolders, failureText(error.message)].filter(Boolean).join(" · "); state.className = "fv-failure"; state.scrollIntoView({block:'nearest'}); }; + const fail = error => { installation.open = true; state.textContent = failureText(error.message); state.className = "fv-failure"; state.scrollIntoView({block:'nearest'}); }; const request = async (action, value) => { const response = await api.fetchApi("/freevideo/setup" + (action ? "/" + action : root.value ? '?root='+encodeURIComponent(root.value) : ""), action ? { method: "POST", headers: { "Content-Type": "application/json", "X-FreeVideo-Setup": token }, body: JSON.stringify(value || {}), @@ -292,7 +294,7 @@ export async function openSetup() { applyToken.onclick = () => setToken(hfToken.value); clearToken.onclick = () => setToken(''); for (const e of [root, extra, copy, samplingCaches]) e.oninput = () => invalidate(true); - extra.oninput = () => { if (savedFolders) state.textContent = ""; savedFolders = ""; savedMissing = false; invalidate(true); }; + extra.oninput = () => { folderStatus.textContent = ""; folderStatus.className = ""; savedFolders = ""; savedMissing = false; invalidate(true); }; accept.onchange = controls; function showPlan(value) { plan.replaceChildren(); @@ -334,7 +336,6 @@ export async function openSetup() { const p = task.progress || {}; state.className = task.status === "failed" ? "fv-failure" : task.ready && !savedMissing ? "fv-ready" : ""; state.textContent = failureText(task.error) || (task.ready ? t("Ready. Generate with your existing workflow.", "准备就绪,可使用现有工作流生成。") : busy ? p.label || t("Checking configuration and local models…", "正在检查配置和本地模型…") : task.status === "complete" && task.action === "plan" ? (planId ? t("Review the plan, then install.", "请确认计划后安装。") : "") : task.status === "cancelled" ? t("Stopped. Files retained for resuming.", "已停止,文件保留,可继续安装。") : ""); - state.textContent = [savedFolders, state.textContent].filter(Boolean).join(" · "); const phases = task.phase_progress || {}, valid = Number.isFinite(p.total) && p.total > 0 && Number.isFinite(p.done) && p.done >= 0 && p.done <= p.total; overall.hidden = !busy; overallBar.max = phases.total > 0 ? phases.total : 1; overallBar.value = phases.done || 0; @@ -361,16 +362,21 @@ export async function openSetup() { const missing = result.missing_libraries || []; savedMissing = missing.length > 0; savedFolders = !missing.length ? t('Saved.', '已保存。') - : missing.length === 1 ? t(`Saved. This folder was not found:\n${missing[0]}`, `已保存。找不到这个目录:\n${missing[0]}`) - : t(`Saved. These folders were not found:\n${missing.join('\n')}`, `已保存。找不到这些目录:\n${missing.join('\n')}`); - state.textContent = savedFolders; state.className = savedMissing ? '' : 'fv-ready'; - state.scrollIntoView({block: 'nearest'}); + : missing.length === 1 ? t('Saved. This folder was not found:', '已保存。找不到这个目录:') + : t(`Saved. ${missing.length} folders were not found:`, `已保存。有 ${missing.length} 个目录找不到:`); + folderStatus.className = savedMissing ? 'fv-warn' : 'fv-ready'; + folderStatus.replaceChildren(savedFolders); + if (savedMissing) { + // The full path is in the box above; show its start and last folder on one line. + const first = missing[0], path = node('span', first.length > 44 ? `${first.slice(0, 14)}…${first.slice(-26)}` : first); + path.className = 'fv-folder-path'; path.title = first; folderStatus.append(path); + } return result; } saveFolders.onclick = async () => { invalidate(true); busy = true; controls(); try { await saveModelFolders(); } - catch (error) { fail(error); } + catch (error) { savedFolders = ''; savedMissing = false; folderStatus.textContent = failureText(error.message); folderStatus.className = 'fv-failure'; } finally { busy = false; controls(); } }; inspect.onclick = async () => { From 3b9f52e4d9adc9f91fd355d958d9393017faca6f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 14:31:33 +0000 Subject: [PATCH 52/53] Editor: keep the project name readable in narrow windows --- web/editor.css | 7 +++++++ web/editor.js | 8 ++++++-- web/editor_header_fit.js | 31 +++++++++++++++++++++++++++++++ web/health.js | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 web/editor_header_fit.js diff --git a/web/editor.css b/web/editor.css index 89de825c..694a5ff8 100644 --- a/web/editor.css +++ b/web/editor.css @@ -189,6 +189,13 @@ } .fv-ed-name-menu{max-height:calc(100dvh - 72px);overflow-y:auto} +/* editor_header_fit.js: when the project name would not stay readable beside a centred view switch, the logo takes its phone size, then leaves, then the switch takes a second row. */ +.fv-studio.fv-editor .fv-header[data-fit~=compact] .fv-wordmark{width:90px} +.fv-studio.fv-editor .fv-header[data-fit~=bare] .fv-wordmark{display:none} +.fv-studio.fv-editor .fv-header[data-fit~=wrap]{height:auto;min-height:98px;grid-template-columns:minmax(0,1fr) auto;padding-block:10px} +.fv-studio.fv-editor .fv-header[data-fit~=wrap]>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center} +.fv-studio.fv-editor .fv-header[data-fit~=wrap]>.fv-header-actions{grid-column:2;grid-row:1} +.fv-studio.fv-editor .fv-header[data-fit~=wrap] .fv-ed-name{max-width:320px} .fv-ed-volume-popover{position:absolute;top:calc(100% + 8px);left:0;display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--fv-border);border-radius:8px;background:var(--fv-card);white-space:nowrap;z-index:8} .fv-ed-volume{width:90px}.fv-ed-volume-value{font-variant-numeric:tabular-nums;font-size:12px;min-width:34px} diff --git a/web/editor.js b/web/editor.js index 084e7aa4..385f35fe 100644 --- a/web/editor.js +++ b/web/editor.js @@ -25,6 +25,7 @@ import {isolateDialogKeys, topDialog} from './dialog_keys.js'; import {createBlockNotes, describedTrack} from './editor_notes.js'; import {createFirstRunGuide} from './first_run_guide.js'; import {createEditorFailures} from './editor_failure.js'; +import {fitEditorHeader} from './editor_header_fit.js'; const editorStyles = Promise.all(['result_actions.css', 'editor.css', 'editor_tracks.css', 'editor_frame.css', 'editor_adjust.css', 'editor_adjust_picture.css', 'editor_preview_picture.css', 'editor_select.css', 'editor_menu.css', 'editor_text.css', 'editor_notes.css', 'editor_transport.css'].map(name => { const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = new URL(name, import.meta.url).href; const ready = 'sheet' in style ? new Promise(resolve => { style.onload = style.onerror = resolve; }) : Promise.resolve(); @@ -177,6 +178,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given exportBox.append(exportHint, exportButton, exportCancel); brand.append(wordmark(), title); headerActions.append(exportBox, button(t('Settings', '设置'), openSetup, 'fv-quiet')); top.append(brand, navigation, headerActions); + const headerFit = fitEditorHeader(top, brand, title, navigation, headerActions); const menu = el('div', 'fv-ed-name-menu'); menu.hidden = true; menu.setAttribute('role', 'menu'); // Up and Down move through the name box and the items; Home and End go to either end. menu.onkeydown = event => { @@ -448,7 +450,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given return !!state.record.id; } function updateTop() { - title.replaceChildren(el('span', '', state.record.name || new Date().toLocaleDateString()), icon('m4 6 4 4 4-4')); title.title = state.record.name; + const name = state.record.name || new Date().toLocaleDateString(); + if (title.firstElementChild?.textContent !== name) { title.replaceChildren(el('span', '', name), icon('m4 6 4 4 4-4')); headerFit.check(); } + title.title = state.record.name; exportButton.classList.toggle('fv-primary', !!model.exportFrames(state.record) && !state.selection && !jobActive() && !exportStatus); const running = exportPreparing || ['running', 'queued'].includes(exportStatus?.state); if (['running', 'queued'].includes(exportStatus?.state)) exportButton.textContent = t(`Export ${Math.round((exportStatus.fraction || 0) * 100)}%`, `导出 ${Math.round((exportStatus.fraction || 0) * 100)}%`); @@ -1183,7 +1187,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given } rememberView(); disposed = true; guide?.dispose(); transportControls.dispose(); toolbarMore.dispose(); player.dispose(); textTools.dispose(); adjust.dispose(); notes.dispose(); pictureEdit.dispose(); - audio.dispose(); + audio.dispose(); headerFit.dispose(); listeners.splice(0).forEach(remove => remove()); for (const node of [element, ...element.querySelectorAll('*')]) for (const name of ['onclick', 'ondblclick', 'oninput', 'onchange', 'onkeydown', 'ondragstart']) node[name] = null; unsubscribe(); stopSources(); library?.dispose(); genbar.dispose(); tracks?.dispose(); stopExport?.(); diff --git a/web/editor_header_fit.js b/web/editor_header_fit.js new file mode 100644 index 00000000..fe58c0a2 --- /dev/null +++ b/web/editor_header_fit.js @@ -0,0 +1,31 @@ +// The view switch stays centred in the editor's header while the project name next to the logo stays readable. +// When it would not, the logo first takes its phone size, then gives its place to the name; if that is still too tight, the switch moves to a second row. +// About six Chinese characters or ten Latin letters of the name itself, without the menu arrow. +const READABLE = 120, COMPACT_MARK = 90; + +export function fitEditorHeader(header, brand, title, navigation, actions) { + if (typeof ResizeObserver !== 'function' || typeof getComputedStyle !== 'function' || typeof matchMedia !== 'function') return {check() {}, dispose() {}}; + const px = value => parseFloat(value) || 0; + function check() { + const span = title.firstElementChild, mark = brand.firstElementChild; + if (!header.isConnected || !span || !header.clientWidth) return; + // Measure the plain layout, so the result never depends on the one applied before. + delete header.dataset.fit; + const box = getComputedStyle(header), gap = px(box.columnGap), inner = header.clientWidth - px(box.paddingLeft) - px(box.paddingRight); + const side = (inner - navigation.offsetWidth - 2 * gap) / 2, spacing = px(getComputedStyle(brand).columnGap); + const own = getComputedStyle(title), chevron = span.nextElementSibling, text = document.createRange(); text.selectNodeContents(span); + const frame = px(own.paddingLeft) + px(own.paddingRight) + px(own.borderLeftWidth) + px(own.borderRightWidth) + (chevron ? chevron.getBoundingClientRect().width + px(own.columnGap) : 0); + const wanted = Math.min(READABLE, text.getBoundingClientRect().width) + frame; + const fits = markWidth => side >= actions.offsetWidth && side - markWidth - spacing >= wanted; + const compact = Math.min(COMPACT_MARK, mark.offsetWidth); + // On two rows the name shares the first row with the actions only; the logo shrinks too when that is still tight. + const rowFits = inner - actions.offsetWidth - gap - mark.offsetWidth - spacing >= wanted; + // From 681 px up the switch has one place in every view, so the logo leaves before the switch moves down. + const bare = !matchMedia('(max-width:680px)').matches && side >= actions.offsetWidth && side >= wanted; + const fit = fits(mark.offsetWidth) ? '' : fits(compact) ? 'compact' : bare ? 'bare' : rowFits ? 'wrap' : 'wrap compact'; + if (fit) header.dataset.fit = fit; + } + const observer = new ResizeObserver(check); + observer.observe(header); observer.observe(navigation); observer.observe(actions); + return {check, dispose: () => observer.disconnect()}; +} diff --git a/web/health.js b/web/health.js index cb0910fa..538e8ed5 100644 --- a/web/health.js +++ b/web/health.js @@ -14,7 +14,7 @@ const t = (en, zh) => cn ? zh : en; const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', - 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', + 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_header_fit.js', 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_terms.js', 'report_issue.js', 'studio_recent.js', 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', From 6193afb9e49825311174134cf031ac1e28a30f35 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 14 Oct 2026 19:38:55 +0000 Subject: [PATCH 53/53] Release 0.4.5 --- freevideo_engine/release_notes.json | 143 +++++++++++++--------------- 1 file changed, 64 insertions(+), 79 deletions(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 19fac486..716afd46 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,78 @@ { "schema": 1, - "product_version": "0.4.4", + "product_version": "0.4.5", "en": { - "summary": "Continuing a video segment by segment no longer stops with a memory error, medium-size videos on Windows with 16 GB or smaller cards are fast again in the second pass, and image generation on Windows no longer stalls. The editing view gains reference pictures and imported-video continuation, and several issues that stopped generation or setup are fixed.", + "summary": "Generation progress now says what is happening with real percentages, image generation explains failures and waits in line behind running work, and the creation panel keeps your settings and media after a refresh. The editing view gains a first-open guide, smoother playback and clearer continued parts, prompts suggest camera and motion words, and many smaller issues are fixed.", "changes": [ - "Continuing a video segment by segment no longer stops with a memory error from the second segment on.", - "Windows: medium-size videos on graphics cards with 16 GB or less no longer slow down in the second pass.", - "Image generation: on Windows it no longer stalls while the image model starts, reports a false out-of-memory error after loading, or stalls while checking downloaded model files; tall pictures no longer show purple vertical lines or a grid texture.", - "After a video finishes, FreeVideo releases graphics and system memory right away unless the next part is about to start, and gives way at once to image generation or other programs.", - "Editing view: the generation bar takes reference pictures (upload one or make one with the image model, then set it as reference, first frame or last frame), every Generate press answers at once, and queued parts stay queued after the page is reloaded and start by themselves; if generation cannot run, they go back to drafts with the reason.", - "Editing view: videos imported from your computer can be extended and continued, files dropped on the timeline land where they are dropped, reopening it from the launcher keeps it usable, and keys pressed in the media library no longer change the timeline.", - "Mac: generating with reference pictures no longer runs out of memory while encoding; the compatibility setting, which only applies to separate graphics cards, is no longer shown.", - "Additional model folders entered in the node settings are now saved and used when FreeVideo looks for models and starts.", - "Edge model files now download from the ModelScope domestic site, and the image model gains an additional mirror source.", - "Image editor: leaving or reloading the page with unsaved changes now asks first. Flipping, resetting, fitting or dragging the locked original works on an automatic copy as one undo step. Renaming a layer by double-click keeps the name, and invalid canvas sizes are explained instead of failing silently.", - "Small mismatches no longer stop the launcher, setup, saved Agent films and cast cards, subtitle import, the image editor, sharing or prompt enhancement; FreeVideo continues and repairs what it can. Turning off or cancelling prompt enhancement while it runs stops it cleanly, and the release link in the update notice opens the latest release.", - "A workflow restored with an empty Total frames value no longer fails with a frame-count error; it generates by duration." + "Progress: generation progress names what is happening during loading, decoding and saving, shows real percentages, and no longer stays on the same line for minutes; the upscale before the second pass keeps reporting while it runs. When a longer run finishes in a background tab, the tab title flashes and a short sound plays; desktop notifications can be turned on in the settings.", + "Image generation: a failure says why on the card and offers the diagnostic report; a request made while the graphics card is busy waits in line; a size that does not fit memory is made smaller automatically; model loading shows progress by what has actually been read; the panel tells a model that is still downloading from one that is not installed; Generate with an empty description says what is missing. Graphics memory is released 45 seconds after the last picture when nothing else is waiting.", + "Creation panel: settings, prompt and media are kept after a refresh, undo and redo; an empty duration, size, seed or step count is asked for instead of reusing an old value; the duration control explains lengths longer than one run; part-by-part videos show cancelled, resumed and finished states after a refresh and say why a part failed; result actions, sharing and the second pass report their progress and failures.", + "Prompts: while you type, the start of a camera or motion word lists matching words in English or Chinese; these suggestions and @ mentions open under the line being typed.", + "Editing view: a short first-open guide that can be skipped; smoother timeline playback, with the next clip made ready before each cut; a failed part says which step went wrong and offers the diagnostic report; export failures say why (missing font characters, a missing or unreadable source, a full disk). Edit, Create and Autopilot in the view switch open even when no node is selected.", + "Editing view timeline: a part that continues the one before it shows a link mark; continued parts keep the ten-second minimum while dragging and fill to ten seconds on release; a part waiting in line still says Queued after a reload; a reopened edit fits its content; the description box and Generate button stay in place while a part is made; on phone-width windows the toolbar stays on one row, with the rest in a More menu.", + "LoRA: each LoRA can carry its own trigger words, added to the prompt whenever it is selected and removed when it is not, in Create, Agent and the editing view. The list keeps the three most recently used LoRAs at the top and has a Clear button.", + "Agent: tools no longer stall on the first call on Windows; conversations can be deleted from the row menu; drafts and failed messages stay after switching conversations, and a retry after an unclear failure no longer sends the message twice. Finished pictures have an Open button with zoom and drag, past chats show a small cover from their first finished video, screenplay problems name the field at fault, and take sheets show one frame per second unless a count is given; on narrow windows the Autopilot header keeps Settings, and the connection shows as a dot.", + "Launcher and offline packages: actions requested while background work is running start once it finishes or say why they are unavailable; update failures tell a full disk from a network problem; offline package imports check file contents before finishing and no longer clash with a cleanup running at the same time.", + "Settings: an empty manual graphics-memory value is reported instead of being ignored, compatibility saves no longer overlap and show their progress, and the placement test shows its progress and Stop button as soon as it starts; the setup Stop button appears only while something is running; the additional model folders box is taller and its save status sits beside the button, in a warning colour when a folder cannot be found.", + "Windows int8: the second pass on large videos chooses its group size from the graphics memory actually available, so it no longer shows a memory warning and retries.", + "Browser page translation leaves the product name as written." ] }, "zh": { - "summary": "一段接一段连续生成不再因内存报错;Windows 上 16 GB 及以下显卡生成中等尺寸视频,第二遍恢复原来的速度;Windows 图像生成不再卡住。剪辑视图可以加参考图、接续导入的视频,另修复了几处会让生成或安装中断的问题。", + "summary": "生成进度会写明正在做什么,百分比按实际进度走;图像生成失败时会说明原因,显卡忙时自动排队;创作面板刷新后保留设置和素材。剪辑视图加了首次打开的引导,播放更顺,接续的段更清楚;写提示词时会提示运镜和动作词;另修复了多处小问题。", "changes": [ - "一段接一段连续生成时,从第二段起不再因内存不足报错。", - "Windows:16 GB 及以下显卡生成中等尺寸视频时,第二遍不再变慢。", - "图像生成:Windows 上图像模型启动时不再卡住、加载完不再误报内存不足,下载后校验文件不再卡住;高图上不再出现紫色竖线和网格纹。", - "视频生成完会立即释放显存和内存;下一段马上开始时才短暂保留,遇到图像生成或其他程序要用时立刻让出。", - "剪辑视图:生成条可以加参考图(上传图片或用图像模型生成一张,可设为参考、首帧或尾帧);每次点“生成”都会立刻有反应,排队的段刷新页面后仍在排队、轮到时自动开始,不能生成时退回草稿并说明原因。", - "剪辑视图:从电脑导入的视频也能往后接续生成,拖到时间线上的文件落在松手的位置;从启动入口再次进入时可以正常操作,素材库里按键不再改动时间线。", - "Mac:用参考图生成时,编码阶段不再报内存不足;只对独立显卡起作用的兼容性设置不再显示。", - "节点设置里填写的“其他模型目录”现在会保存,查找模型和启动时都会用到。", - "Edge 模型文件改从魔搭国内站下载,图像模型增加一个镜像下载源。", - "图片编辑器:有未保存的改动时离开或刷新页面会先提示;对已锁定的原图翻转、重置、适配或拖动时,自动在副本上操作并算一步撤销;双击重命名图层保留名称;画布尺寸不合法时给出提示而不是无反应。", - "启动器、安装、Agent 已存的影片和角色卡、字幕导入、图片编辑器、分享和提示词改写遇到小偏差不再中断,FreeVideo 会继续并自动修复能修的部分;提示词增强运行中关闭或取消时会正常停止,更新提示里的发布页链接打开最新版本。", - "恢复的工作流把“总帧数”留空时,生成不再因帧数校验报错,按时长正常生成。" + "进度:生成进度会写明载入、解码、保存各在做什么,百分比按实际进度走,不再长时间停在同一句;第二遍前的放大过程中进度持续更新。较长的生成在后台标签页完成时,标签标题会闪烁并响一声短提示音;在设置里可以打开桌面通知。", + "图像生成:失败时卡片上写明原因,并可提交诊断报告;显卡忙时发起的生成自动排队;尺寸超出显存时自动缩小;模型载入进度按实际读取量显示;能区分模型“下载中”和“未安装”;描述为空时点生成会提示缺什么。最后一张图完成 45 秒后,如果没有别的任务在等,就释放显存。", + "创作面板:刷新、撤销和重做后,设置、提示词和素材都会保留;时长、尺寸、种子或步数留空时会提示填写,不再沿用旧值;时长控件会说明超过一次能生成的长度时怎么处理;分段生成的视频刷新后能显示已取消、已继续和已完成,失败的段会说明原因;结果操作、分享和第二遍都会显示进度和失败原因。", + "提示词:输入运镜或动作词的开头时,会列出匹配的词,中英文都可以;这些候选和 @ 选择器跟在正在输入的那一行下面。", + "剪辑视图:首次打开时有一段简短引导,可以跳过;时间线播放更顺,换段前先把下一段准备好;某一段失败时说明是哪一步出的错,并可提交诊断报告;导出失败会说明原因(字体缺字、源文件缺失或读不出、磁盘已满)。没有选中节点时,视图切换里的剪辑、创作、全自动也能打开。", + "剪辑视图时间线:接续上一段的部分带链环标记;接着生成的每一段在拖动时就保证至少 10 秒,松手后自动补齐;排队的段刷新后仍显示“排队中”;重新打开的作品按内容适配缩放;生成一段时描述框和“生成”按钮留在原位;手机宽度的窗口里工具栏保持一行,其余放进“更多”菜单。", + "LoRA:每个 LoRA 可以带自己的触发词,选中时自动加进提示词,取消时去掉,创作、Agent 和剪辑视图都适用。列表把最近用过的三个放在最上面,并新增“清除”按钮。", + "Agent:Windows 上第一次调用工具不再卡住;可以在对话行的菜单里删除对话;切换对话后草稿和发送失败的消息仍在,失败原因不明时重试不会把消息发两遍。做好的图片可以点“打开”放大、拖动查看;历史对话显示第一段做成的视频的小封面;剧本有问题时写明是哪个字段;审片拼图默认每秒一帧;窄窗口下全自动页顶栏保留“设置”,连接状态显示为圆点。", + "启动器和离线包:后台任务进行中点的操作,会在任务结束后执行一次,或者说明暂时不能用的原因;更新失败时区分磁盘已满和网络问题;导入离线包会先核对文件内容再完成,不再和同时进行的清理冲突。", + "设置:手动显存留空时会提示,不再被忽略;兼容性设置的保存不会重叠,并显示进度;放置测试一开始就显示进度和停止按钮;安装页的停止按钮只在运行时出现;其他模型目录输入框加高,保存结果显示在按钮旁,找不到目录时用警示色。", + "Windows int8:生成较大的视频时,第二遍按实际可用的显存选分组大小,不再弹显存提示后重试。", + "浏览器翻译页面时不再翻译产品名。" ] }, "history": [ + { + "product_version": "0.4.4", + "en": { + "summary": "Continuing a video segment by segment no longer stops with a memory error, medium-size videos on Windows with 16 GB or smaller cards are fast again in the second pass, and image generation on Windows no longer stalls. The editing view gains reference pictures and imported-video continuation, and several issues that stopped generation or setup are fixed.", + "changes": [ + "Continuing a video segment by segment no longer stops with a memory error from the second segment on.", + "Windows: medium-size videos on graphics cards with 16 GB or less no longer slow down in the second pass.", + "Image generation: on Windows it no longer stalls while the image model starts, reports a false out-of-memory error after loading, or stalls while checking downloaded model files; tall pictures no longer show purple vertical lines or a grid texture.", + "After a video finishes, FreeVideo releases graphics and system memory right away unless the next part is about to start, and gives way at once to image generation or other programs.", + "Editing view: the generation bar takes reference pictures (upload one or make one with the image model, then set it as reference, first frame or last frame), every Generate press answers at once, and queued parts stay queued after the page is reloaded and start by themselves; if generation cannot run, they go back to drafts with the reason.", + "Editing view: videos imported from your computer can be extended and continued, files dropped on the timeline land where they are dropped, reopening it from the launcher keeps it usable, and keys pressed in the media library no longer change the timeline.", + "Mac: generating with reference pictures no longer runs out of memory while encoding; the compatibility setting, which only applies to separate graphics cards, is no longer shown.", + "Additional model folders entered in the node settings are now saved and used when FreeVideo looks for models and starts.", + "Edge model files now download from the ModelScope domestic site, and the image model gains an additional mirror source.", + "Image editor: leaving or reloading the page with unsaved changes now asks first. Flipping, resetting, fitting or dragging the locked original works on an automatic copy as one undo step. Renaming a layer by double-click keeps the name, and invalid canvas sizes are explained instead of failing silently.", + "Small mismatches no longer stop the launcher, setup, saved Agent films and cast cards, subtitle import, the image editor, sharing or prompt enhancement; FreeVideo continues and repairs what it can. Turning off or cancelling prompt enhancement while it runs stops it cleanly, and the release link in the update notice opens the latest release.", + "A workflow restored with an empty Total frames value no longer fails with a frame-count error; it generates by duration." + ] + }, + "zh": { + "summary": "一段接一段连续生成不再因内存报错;Windows 上 16 GB 及以下显卡生成中等尺寸视频,第二遍恢复原来的速度;Windows 图像生成不再卡住。剪辑视图可以加参考图、接续导入的视频,另修复了几处会让生成或安装中断的问题。", + "changes": [ + "一段接一段连续生成时,从第二段起不再因内存不足报错。", + "Windows:16 GB 及以下显卡生成中等尺寸视频时,第二遍不再变慢。", + "图像生成:Windows 上图像模型启动时不再卡住、加载完不再误报内存不足,下载后校验文件不再卡住;高图上不再出现紫色竖线和网格纹。", + "视频生成完会立即释放显存和内存;下一段马上开始时才短暂保留,遇到图像生成或其他程序要用时立刻让出。", + "剪辑视图:生成条可以加参考图(上传图片或用图像模型生成一张,可设为参考、首帧或尾帧);每次点“生成”都会立刻有反应,排队的段刷新页面后仍在排队、轮到时自动开始,不能生成时退回草稿并说明原因。", + "剪辑视图:从电脑导入的视频也能往后接续生成,拖到时间线上的文件落在松手的位置;从启动入口再次进入时可以正常操作,素材库里按键不再改动时间线。", + "Mac:用参考图生成时,编码阶段不再报内存不足;只对独立显卡起作用的兼容性设置不再显示。", + "节点设置里填写的“其他模型目录”现在会保存,查找模型和启动时都会用到。", + "Edge 模型文件改从魔搭国内站下载,图像模型增加一个镜像下载源。", + "图片编辑器:有未保存的改动时离开或刷新页面会先提示;对已锁定的原图翻转、重置、适配或拖动时,自动在副本上操作并算一步撤销;双击重命名图层保留名称;画布尺寸不合法时给出提示而不是无反应。", + "启动器、安装、Agent 已存的影片和角色卡、字幕导入、图片编辑器、分享和提示词改写遇到小偏差不再中断,FreeVideo 会继续并自动修复能修的部分;提示词增强运行中关闭或取消时会正常停止,更新提示里的发布页链接打开最新版本。", + "恢复的工作流把“总帧数”留空时,生成不再因帧数校验报错,按时长正常生成。" + ] + } + }, { "product_version": "0.4.3", "en": { @@ -221,58 +258,6 @@ "修复:引擎更新后,用引擎自带 Python 运行的命令也会用新版 FreeVideo;Linux 上安装目录里的 uv 被换成链接时,“安装 / 修复”也能完成。" ] } - }, - { - "product_version": "0.3.23", - "en": { - "summary": "Generation and installation progress show one overall percentage with the details that matter, such as the current step and how much has been downloaded; the workspace fills the window; RTX 50 GPUs no longer keep pausing when the driver cache is full, and the first video after a start waits less.", - "changes": [ - "The first video after FreeVideo starts waits up to about 15 seconds less for the text encoder. On RTX 40 and RTX 50 cards under Windows, and on Linux, more of the video decoder stays on the graphics card while decoding (all of it on 12 GB cards), so decoding is faster.", - "On Windows with RTX 50 GPUs, generation no longer keeps pausing once the graphics driver's compile cache is full.", - "On Windows, when the video model fits neither in GPU memory nor in RAM, generation waits less for the drive. When another program takes GPU memory during generation, decoding no longer starts over because it ran out of memory.", - "Generation progress shows one overall percentage, the current step and the elapsed time, with a small bar under slow stages, and the remaining time appears after the first two steps; during installation, repair and updates, a download shows what is being downloaded, how much and how fast. Loading the video model no longer hangs when the graphics driver does not answer, and after a long time without progress the page says that generation is still running and offers the report.", - "Error cards now say in one sentence what happened and what to do, with the raw error under Technical details; cases such as a reference video that is too short or a LoRA that cannot be used with the int8 model yet each have their own message. The compatibility setting now describes what each level does.", - "The creative workspace and the Creations window now fill the browser window, so the video preview grows with it. On windows at least 1680 px wide, a Recent videos column appears on the right; clicking a video plays it in the preview, and a button in the preview header hides the column.", - "A custom step count now downloads only a sampling table the first time, up to 1.2 GB, instead of 26 GB of model weights. Any download still needed, as for some LoRAs on FP8, shows its progress, size and speed instead of an unchanging “Loading model”.", - "On NVIDIA graphics cards with 8 GB or more, prompt enhancement needs about 4 GiB of free RAM instead of 11 GiB, and on 8 GB and 10 GB cards a rewrite takes tens of seconds instead of several minutes. While it runs, the status line shows how long it has taken.", - "Each saved video now takes far less disk space, about 40 MB instead of about 790 MB for a 10-second 1344 × 768 video, because the uncompressed video and audio are no longer kept. The first start after updating deletes them from earlier videos as well, and previews can still continue with Run second pass.", - "A new Repair button in the launcher fixes an installation in one step and keeps models and videos; it appears on the error card when the launcher finds a problem it can repair, and in Settings › Environment. A repair that only adds a few packages no longer asks for as much free disk space as a new installation.", - "On a Mac, when unified memory runs short, the second pass switches to a computation that uses less memory; if it still runs short, the first pass is kept and only the second pass runs again. When less memory is available than a video needs, FreeVideo says so as soon as generation starts.", - "Fixes: on Linux, desktops without xdg-open can open FreeVideo in the browser, and the background service starts when the folder name contains %, $, quotes or backslashes; when port 8188 is in use, the launcher starts ComfyUI on a free port and shows the new address; and Captions only in the image editor no longer leaves white specks when it sweeps a caption in several strokes. With a self-installed ComfyUI updated to a recent version, each video appears once in Assets instead of twice, and paths with hidden folders no longer show in the launcher terminal and diagnostic reports." - ] - }, - "zh": { - "summary": "生成和安装的进度只显示一个总百分比,并给出在做什么、下了多少等必要的细节;创作面板铺满整个窗口;RTX 50 显卡不再因驱动缓存写满而反复停顿,启动后的第一个视频等待更短。", - "changes": [ - "启动后的第一个视频等文本编码器的时间更短,最多少等约 15 秒。在 Windows 上的 RTX 40、RTX 50 显卡以及 Linux 上,解码时更多的解码器留在显卡上(12 GB 显卡可以全部留下),解码更快。", - "在使用 RTX 50 系显卡的 Windows 电脑上,显卡驱动的编译缓存写满后,生成时不再反复停顿。", - "在 Windows 上,显存和内存都放不下整个视频模型时,生成时等硬盘的时间更短。生成期间其他程序占用了显存时,解码不再因显存不足而从头再来一遍。", - "生成进度只显示一个总百分比、正在做的那一步和已用时间,慢的阶段下面多一条小进度条,跑完两步后再显示预计剩余时间;安装、修复和更新时,下载会写明在下什么、下了多少和速度。加载视频模型时不再因显卡驱动没有响应而一直卡住;长时间没有新的进度时,页面会说明生成仍在进行,并可以下载报告。", - "出错时,卡片用一句话说明发生了什么、该怎么做,原始报错收进“技术详情”;参考视频太短、LoRA 暂时不能用于 int8 模型等情况各有自己的说明。兼容性设置也改成说明每一档的效果。", - "创作面板和作品窗口现在铺满浏览器窗口,视频预览随窗口一起变大。窗口宽 1680 像素以上时,右侧会多出“最近作品”一栏,点击其中的视频会在预览中播放,预览标题行的按钮可以收起这一栏。", - "选用自定义步数时,第一次只需下载一个采样表(最多 1.2 GB),不再先下载 26 GB 的模型权重。仍需下载时(例如 FP8 上的部分 LoRA),会显示进度、大小和速度,不再一直停在“加载模型”。", - "在 8 GB 及以上的 NVIDIA 显卡上,提示词增强只需约 4 GiB 可用内存(原来要 11 GiB),8 GB 和 10 GB 显卡上的改写从几分钟缩短到几十秒。改写时,状态栏会显示已用时间。", - "保存的视频占用的磁盘空间大幅减少:10 秒、1344 × 768 的视频从约 790 MB 降到约 40 MB,因为不再保留未压缩的画面和音频。更新后第一次启动时,也会删除之前视频里的这些文件;预览仍然可以点“继续二采”。", - "启动器新增“修复”按钮,一步修好安装,模型和视频都会保留;发现可以修复的问题时,出错卡片上会出现这个按钮,“设置 → 环境”里也有。只需补装几个包的修复,不再要求和全新安装一样多的空闲磁盘空间。", - "在 Mac 上,统一内存不足时,第二遍会改用占用内存更少的计算方式;仍然不足时,保留第一遍的结果,只重新运行第二遍。可用内存低于所需时,生成开始时就会给出提示。", - "修复:Linux 上,没有 xdg-open 的桌面也能在浏览器中打开 FreeVideo,文件夹名带 %、$、引号或反斜杠时后台服务也能启动;本机 8188 端口被占用时,启动器会改用空闲端口并说明新地址;图片编辑器的“只去字幕”分几笔扫过字幕时不再留下白点。自行将 ComfyUI 更新到较新版本后,每个视频在“资产”中不再出现两条记录;启动器终端和诊断报告里,隐去文件夹的路径不再显示为 。" - ] - } - }, - { - "product_version": "0.3.22", - "en": { - "summary": "The image editor opens again after installing or updating with the launcher.", - "changes": [ - "The image editor opens again. In 0.3.21 installed or updated with the launcher, clicking Edit only showed \"The image could not be opened for editing.\" because the fonts the editor uses were missing from the package." - ] - }, - "zh": { - "summary": "通过启动器安装或更新后,图片编辑器可以正常打开了。", - "changes": [ - "图片编辑器可以正常打开了。通过启动器安装或更新的 0.3.21 缺少编辑器用的字体,点“编辑”只会提示“这张图片无法打开编辑。”。" - ] - } } ] }