From e2a00dd0ef8ae7b84ea8b67c7591ab305d393c27 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:19 +0000 Subject: [PATCH 01/26] Load the image model from read-only mappings when a Windows machine has too little commit for... Load the image model from read-only mappings when a Windows machine has too little commit for private copies --- freevideo_engine/image_gen/image_runner.py | 37 ++++++++++++++++--- freevideo_engine/image_gen/qwen_te_weights.py | 15 +++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/freevideo_engine/image_gen/image_runner.py b/freevideo_engine/image_gen/image_runner.py index 8957c7d6..1f8649e8 100644 --- a/freevideo_engine/image_gen/image_runner.py +++ b/freevideo_engine/image_gen/image_runner.py @@ -17,6 +17,7 @@ from .image_stream import ResidentStack, StreamedStack GiB = 2**30 +COMMIT_FLOOR = 2 * GiB # commit that must remain after private copies of the weights _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' @@ -141,9 +142,15 @@ def path(directory, name): # An Apple GPU shares the host's memory: its layers stay views of the mapped files (mapped.py). self.mapped = self.device.type == 'mps' self.full_card = not self.mapped and self._fits_whole_model() + # The weights are read from read-only mappings of the files, not copied into private memory, + # on a Mac and on a Windows machine whose commit cannot hold the copies. Such a machine pins + # nothing: the commit left after the weights is what the card's own allocations are charged to. + low_commit = not self.mapped and self._copies_exceed_commit() + mapped_files = {'mapped': True} if self.mapped or low_commit else {} + self.low_commit = low_commit self.transformer = load_dit(self.pack_dir / _DIT_FILE, device=self.device if self.full_card else 'cpu', progress=lambda done, total: loading.report('dit', done, total), - **({'mapped': True} if self.mapped else {})) + **mapped_files) self.load_seconds['dit'] = time.monotonic() - started loading.complete('dit') started = time.monotonic() @@ -156,7 +163,7 @@ def path(directory, name): processor_dir = self.pack_dir / 'processor' 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), - **({'mapped': True} if self.mapped else {})) + **mapped_files) from transformers import Qwen3VLProcessor processor = Qwen3VLProcessor.from_pretrained(str(processor_dir), local_files_only=True) self.load_seconds['text'] = time.monotonic() - started @@ -206,10 +213,10 @@ def pin_progress(index): else: with torch.no_grad(): # LayerOffloader refuses to be built with autograd on self.dit_stack = StreamedStack(stacks[0], name='dit', device=self.device, - progress=pin_progress(0)) + pin=not low_commit, 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)) + pin=not low_commit, progress=pin_progress(1)) self.load_seconds['pin'] = time.monotonic() - started loading.complete('pin') @@ -224,6 +231,25 @@ def pin_progress(index): self.last_plan = None loading.finish() + def _copies_exceed_commit(self): + """True on Windows when private copies of the DiT and encoder would leave too little commit. + + System commit is charged for every private byte: with 15.8 GB of RAM, 11.7 GB of commit free + and 16.5 GB of weights, the encoder's read failed with an access violation. A read-only + mapping costs no commit; the pages are re-read from the file when the system needs the + memory, and pinning (which checks live commit itself) still locks what fits. + """ + from ..system import windows + if not windows(): + return False + try: + from ..system import system_memory + commit = system_memory().get('commit_available_bytes') + copies = sum((self.pack_dir / name).stat().st_size for name in (_DIT_FILE, _TE_FILE, _VAE_FILE)) + except Exception: + return False + return commit is not None and commit < copies + COMMIT_FLOOR + def _lookup_on_host(self, embedding): device = self.device @@ -320,7 +346,8 @@ def at_boundary(self): def stats(self): return dict(dit=self.dit_stack.stats(), text_encoder=self.te_stack.stats(), - plan=self.last_plan.to_dict() if self.last_plan else None, load_seconds=self.load_seconds) + plan=self.last_plan.to_dict() if self.last_plan else None, load_seconds=self.load_seconds, + low_commit_load=self.low_commit) def close(self): for hook in self._embed_hooks: diff --git a/freevideo_engine/image_gen/qwen_te_weights.py b/freevideo_engine/image_gen/qwen_te_weights.py index 7765a9fd..7d4a4d1f 100644 --- a/freevideo_engine/image_gen/qwen_te_weights.py +++ b/freevideo_engine/image_gen/qwen_te_weights.py @@ -20,6 +20,16 @@ _CONVROT = {"format": "int8_tensorwise", "convrot": True, "convrot_groupsize": 256} +def _open(path): + """The encoder file, opened like the DiT and VAE (streamed_weights._open_safetensors). + + Windows charges a copy-on-write mapping of a whole file to system commit, 9.35 GB for this + one, however little of it is read; the pread backend reads only the requested tensors. + """ + from ..system import windows + return safe_open(str(path), framework="pt", device="cpu", **({"backend": "pread"} if windows() else {})) + + def _quant_metadata(st, name): value = st.get_tensor(name + ".comfy_quant") if value.dtype != torch.uint8 or value.ndim != 1: @@ -168,6 +178,7 @@ 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. + ``mapped`` leaves tensors stored as they are on disk as views of a read-only mapping of the file. ``progress(done_bytes, total_bytes)`` counts language source bytes, including vocabulary chunks, and excludes the deferred vision tensors and logits head. """ @@ -194,7 +205,7 @@ def load_text_encoder(path, *, config=None, config_path=None, device="cpu", dtyp vision_type = type(model.model.visual) model.model.visual = None model.lm_head = torch.nn.Identity() - with safe_open(str(path), framework="pt", device="cpu") as st: + with _open(path) as st: 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) if mapped: # CPU only: tensors stored as they are on disk stay views of the mapped file @@ -212,7 +223,7 @@ def load_vision(device="cpu"): return owner.model.visual with init_empty_weights(): visual = vision_type(config.vision_config) - with safe_open(str(path), framework="pt", device="cpu") as st: + with _open(path) as st: visual_keys = [key for key in st.keys() if key.startswith("model.visual.")] for key in sorted(visual_keys): value = st.get_tensor(key) From 9ca65cc757b06381996d81e9367e4b1819225732 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 02/26] Retry once with a fresh worker after a native crash while an image model is loading --- freevideo_engine/image_gen/service.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 4c1b57bc..36d1cd21 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -1053,9 +1053,12 @@ def fitted(length): return True @staticmethod - def worker_gone(session, heard, requested_stop=False): - """The error for a worker pipe that ended; it carries the exit code for the report.""" - error = Service.worker_gone_error(session, heard) + def worker_gone(session, heard, requested_stop=False, loading=False): + """The error for a worker pipe that ended; it carries the exit code for the report. + + ``loading`` is true while the worker has reported nothing beyond loading its weights. + """ + error = Service.worker_gone_error(session, heard, loading=loading, requested_stop=requested_stop) code = getattr(error, 'exit_code', None) if code is not None: # The worker cannot report its memory once it is gone: read this process's view now. @@ -1069,7 +1072,7 @@ def worker_gone(session, heard, requested_stop=False): return error @staticmethod - def worker_gone_error(session, heard): + def worker_gone_error(session, heard, loading=False, requested_stop=False): try: code = session.process.wait(timeout=2) except subprocess.TimeoutExpired: @@ -1088,7 +1091,11 @@ def worker_gone_error(session, heard): message = 'worker_exit (exit code %d%s)' % (code, ', ' + kind if kind else '') # A worker that ended with an error before answering never ran the request; one # fresh worker gets it, and a second exit is reported with its own last words. - error = (WorkerEarlyExit if not heard and code > 0 else RuntimeError)(message) + # A native crash while the weights were still loading ran nothing of the request either. + from ..exit_diagnostics import exit_reading + crashed_loading = (loading and code != 0 and not requested_stop + and exit_reading(code).get('exit_class') == 'native_crash') + error = (WorkerEarlyExit if (not heard and code > 0) or crashed_loading else RuntimeError)(message) error.exit_code = code return error @@ -1100,6 +1107,7 @@ def infer(self, session, value): keep.note('image', habit) # How long the worker may wait for the next picture; it applies that only with the whole model on the card. value['keep_seconds'] = keep.keep_seconds(habit) + loading = True try: session.send(action='generate', **value) except OSError: @@ -1121,11 +1129,12 @@ def infer(self, session, value): except queue.Empty: continue if row is None: - raise self.worker_gone(session, heard, self.stop.is_set()) + raise self.worker_gone(session, heard, self.stop.is_set(), loading=loading) heard = True phase = row.get('phase') if phase == 'progress': stage, fraction = row.get('stage'), row.get('fraction') + loading = loading and stage in ('start', 'load') 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. From 2f737c526084f89af2e751eac5ce30f0366ce72c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 03/26] Say where access was denied on the launcher's failure card, and keep the details in its report --- freevideo_engine/comfy_launcher_runtime.py | 9 +- freevideo_engine/denied_access.py | 158 +++++++++++++++++++++ freevideo_engine/failure_details.py | 2 +- freevideo_engine/launcher_session.py | 16 ++- 4 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 freevideo_engine/denied_access.py diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index c78a85a9..7feb804f 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -1026,7 +1026,7 @@ def run(self, operation, *args): # A link check in flight finishes first, so it cannot overwrite this task's state. poll.join() self.cancelled.clear() - self.state = dict(self.state, status='running', action=operation, error=None, exception=None, task={}, overall={}, + self.state = dict(self.state, status='running', action=operation, error=None, exception=None, denied=None, task={}, overall={}, model_groups=[] if operation == 'inspect' else self.state.get('model_groups', [])) if operation in ('connect', 'launch', 'configure_access'): self.sections = [('open', 1)] @@ -1041,8 +1041,13 @@ def work(): # A remembered URL alone cannot prove the local page is usable. opened = bool(self.state.get('url')) and server_info(self.state['url'].split('/?')[0])['status'] == 'freevideo' status = 'open' if opened else 'failed' + from .denied_access import describe + # Inspection fails before a selection exists, so the requested folder stands in. + engine = (self.selection or {}).get('engine') or ( + args[0].get('engine') if args and isinstance(args[0], dict) else None) + roots = [('INSTALL', engine)] if engine else [] self.state = dict(self.state, status='cancelled' if self.cancelled.is_set() else status, - error=str(error), exception=exception_details(error)) + error=str(error), exception=exception_details(error), denied=describe(error, roots)) self.thread = threading.Thread(target=work, daemon=True) self.thread.start() diff --git a/freevideo_engine/denied_access.py b/freevideo_engine/denied_access.py new file mode 100644 index 00000000..890e75b9 --- /dev/null +++ b/freevideo_engine/denied_access.py @@ -0,0 +1,158 @@ +"""Where an access-denied failure happened, worded without a user name or a private folder. + +Windows reports "[WinError 5] Access is denied" for many different operations, and when the call +names no file the message says nothing else. The launcher keeps this description next to the +message: the folder as the drive letter plus the part inside FreeVideo's own folders, and the last +FreeVideo functions that were running. +""" +import errno +import os +import re +import tempfile + +DENIED_ERRNO = (errno.EACCES, errno.EPERM) +# Folders that stay in the description when the denied path is inside one of them. +OWN_NAME = re.compile(r'^(?:freevideo|fv$)', re.I) +# The installation folder the launcher creates by default; it is kept even where no installation root is known. +ENGINE_NAME = re.compile(r'^freevideo-engine$', re.I) +# Folders named below FreeVideo's own folder: user-named files (models, inputs, outputs) sit deeper. +INSIDE = 2 +STEPS = 3 +SOCKET_DENIED = 10013 + + +def is_denied(error): + if getattr(error, 'winerror', None) == SOCKET_DENIED: + # A port that Windows reserved is not a refused file or folder. + return False + return isinstance(error, PermissionError) or (isinstance(error, OSError) and ( + getattr(error, 'winerror', None) == 5 or error.errno in DENIED_ERRNO)) + + +def _segments(path): + path = str(path) + if path.startswith('\\\\?\\') and not path.startswith('\\\\?\\UNC\\'): + path = path[4:] + drive = '' + match = re.match(r'([A-Za-z]):(?:[\\/]|$)', path) + if match: + drive, path = match.group(1).upper() + ':', path[match.end():] + return drive, [part for part in re.split(r'[\\/]+', path) if part] + + +def _under(parts, root): + return len(parts) >= len(root) and all(a.casefold() == b.casefold() for a, b in zip(parts, root)) + + +def _inside(parts): + """The first folders below one of FreeVideo's own, then an ellipsis where more follow.""" + return parts[:INSIDE] + (['…'] if len(parts) > INSIDE else []) + + +def default_roots(): + """The user's own folders, which are named by a placeholder and never spelled out.""" + roots = [] + for label, value in (('HOME', os.path.expanduser('~')), ('TEMP', tempfile.gettempdir()), + ('PROGRAMDATA', os.environ.get('ProgramData', ''))): + if value and value != '~': + roots.append((label, value)) + return roots + + +def location(path, roots=()): + """A path as "[D:] INSTALL\\launcher": the drive letter and a placeholder in place of every folder that + is not FreeVideo's own. The installation root is the engine folder itself, so no folder above it is named, + and nothing below it deeper than INSIDE folders. The text does not look like a path to the launcher's redaction.""" + path = str(path or '') + if not path: + return '' + if path.startswith(('\\\\', '//')) and (not path.startswith('\\\\?\\') or path.upper().startswith('\\\\?\\UNC\\')): + return '[network share]' + drive, parts = _segments(path) + best = None + for label, root in list(roots) + default_roots(): + root_drive, root_parts = _segments(root) + # A root with no folder (a whole drive) would make every path on it FreeVideo's own. + if root_parts and root_drive == drive and _under(parts, root_parts) and ( + best is None or len(root_parts) > len(best[1])): + best = (label, root_parts) + prefix = '[%s] ' % drive if drive else '' + if best is None: + # A name outside FreeVideo's folders may be a person, a project or a video, so none is kept, + # except the engine folder the launcher creates by default. + own = next((index for index, part in enumerate(parts) if ENGINE_NAME.match(part)), None) + if own is not None: + return prefix + '\\'.join(['…', parts[own]] + _inside(parts[own + 1:])) + return prefix + '…' if parts else prefix.strip() + label, root_parts = best + rest = parts[len(root_parts):] + if label in ('HOME', 'TEMP'): + # Only what FreeVideo created below the user's folder is named. + own = next((index for index, part in enumerate(rest) if OWN_NAME.match(part)), None) + rest = ['…'] + ([rest[own]] + _inside(rest[own + 1:]) if own is not None else []) + elif label == 'INSTALL': + rest = _inside(rest) + return prefix + '\\'.join([label] + rest) + + +def _chain(error): + seen = set() + while isinstance(error, BaseException) and id(error) not in seen: + seen.add(id(error)) + yield error + error = error.__cause__ or (None if error.__suppress_context__ else error.__context__) + + +def steps(error): + """The innermost FreeVideo functions of the traceback, newest first: "processes.popen:211".""" + rows, tb = [], error.__traceback__ + while tb is not None: + module = str(tb.tb_frame.f_globals.get('__name__') or '') + if module == 'freevideo_engine' or module.startswith('freevideo_engine.'): + rows.append('%s.%s:%d' % (module.partition('.')[2] or module, tb.tb_frame.f_code.co_name, tb.tb_lineno)) + tb = tb.tb_next + return rows[-STEPS:][::-1] + + +def describe(error, roots=()): + """The access-denied error in this exception chain as plain data, or None when there is none.""" + for item in _chain(error): + if is_denied(item): + winerror = getattr(item, 'winerror', None) + result = dict(code=winerror or item.errno, windows=bool(winerror), steps=steps(item)) + paths = [location(name, roots) for name in (item.filename, item.filename2) if name] + if paths: + result['path'] = ' → '.join(paths) + return result + return None + + +def text(info, zh=False): + """The line kept in the report: what was denied, where, and the FreeVideo steps that were running.""" + if not info: + return '' + label = (('Windows 错误 %s' if zh else 'Windows error %s') if info.get('windows') else ('错误 %s' if zh else 'error %s')) + code = ('(%s)' if zh else ' (%s)') % (label % info['code']) if info.get('code') else '' + if info.get('path'): + head = ('访问被拒绝%s:%s' if zh else 'Access denied%s: %s') % (code, info['path']) + else: + head = ('访问被拒绝%s,系统没有说明是哪个文件或文件夹' if zh else + 'Access denied%s; the system did not name a file or folder') % code + if info.get('steps'): + head += (';步骤:' if zh else '; steps: ') + ' < '.join(info['steps']) + return head + + +PLACEHOLDERS = (('INSTALL', '[安装目录]', '[installation folder]'), ('HOME', '[用户文件夹]', '[user folder]'), + ('TEMP', '[临时文件夹]', '[temp folder]'), ('[network share]', '[网络共享]', '[network share]')) + + +def card_text(info, zh=False): + """The line on the failure card: only where access was denied, with readable names for the placeholders.""" + value = (info or {}).get('path') + if not value: + return '' + for token, chinese, english in PLACEHOLDERS: + value = re.sub(r'(? Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 04/26] Licenses: Apache-2.0 text for the music text encoder; music model rows in third-party notices --- THIRD_PARTY_NOTICES.md | 3 + freevideo_engine/image_gen/image_pack.py | 3 +- .../licenses/Qwen3-Embedding-Apache-2.0.txt | 204 ++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5f7e5ba5..49829717 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -20,6 +20,9 @@ FreeVideo code uses [Apache-2.0](LICENSE). Its dependencies and models retain th | [ONNX Runtime](https://github.com/microsoft/onnxruntime) 1.28.2 (CPU), © Microsoft Corporation; its third-party notices are shipped inside the sherpa-onnx wheel | MIT | | [Eigen](https://gitlab.com/libeigen/eigen) 5.0.1, compiled into sherpa-onnx and ONNX Runtime; source: https://gitlab.com/libeigen/eigen/-/tree/5.0.1 | MPL-2.0 | | [Silero VAD](https://github.com/snakers4/silero-vad) v5.0 voice-activity model, © 2020-present Silero Team (optional download with subtitles, license file downloaded with the model; the onnx-community export is downloaded with the music model) | [MIT](freevideo_engine/licenses/Silero-VAD-MIT.txt) | +| [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) XL turbo music model, © 2026 ACEStep (optional download for music; inference subset adapted in `freevideo_engine/music/acestep_model.py`) | [MIT](freevideo_engine/licenses/ACE-Step-MIT.txt) | +| [vector-quantize-pytorch](https://github.com/lucidrains/vector-quantize-pytorch), © 2020 Phil Wang (residual FSQ decoding adapted in `freevideo_engine/music/acestep_model.py`) | [MIT](freevideo_engine/licenses/Vector-Quantize-PyTorch-MIT.txt) | +| [Qwen3-Embedding-0.6B](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B), © Alibaba Cloud (text encoder of the music model, downloaded with it) | [Apache-2.0](freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt) | | [NVIDIA NeMo stt_en_conformer_ctc_small](https://huggingface.co/nvidia/stt_en_conformer_ctc_small), © NVIDIA, exported to ONNX and quantized to int8 by the sherpa-onnx project, changed from the original; not endorsed by NVIDIA (optional download for English subtitles; [notice](freevideo_engine/licenses/NeMo-stt_en_conformer_ctc_small-NOTICE.txt)) | [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/) | | [OpenAI Whisper tiny](https://github.com/openai/whisper), © 2022 OpenAI, exported to ONNX and quantized to int8 by the sherpa-onnx project (optional download for spoken-language detection) | [MIT](freevideo_engine/licenses/Whisper-MIT.txt) | | [SenseVoiceSmall](https://huggingface.co/FunAudioLLM/SenseVoiceSmall) by FunASR / FunAudioLLM (Alibaba Group), converted to ONNX and quantized to int8 by the sherpa-onnx project, used unchanged under its original name (optional download for multilingual subtitles; [notice](freevideo_engine/licenses/SenseVoice-NOTICE.txt)) | [FunASR Model Open Source License Agreement v1.1](freevideo_engine/licenses/FunASR-MODEL_LICENSE-v1.1.txt) | diff --git a/freevideo_engine/image_gen/image_pack.py b/freevideo_engine/image_gen/image_pack.py index 4b252f07..16ac5926 100644 --- a/freevideo_engine/image_gen/image_pack.py +++ b/freevideo_engine/image_gen/image_pack.py @@ -229,7 +229,8 @@ def _owned_intact(path, stamp): b'https://github.com/facebookresearch/sam2\n') MUSIC_NOTICE_TEXT = (b'ACE-Step 1.5, Copyright (c) 2026 ACEStep. Licensed under the MIT License; see ' b'freevideo_engine/licenses/ACE-Step-MIT.txt.\nhttps://github.com/ace-step/ACE-Step-1.5\n' - b'Qwen3-Embedding-0.6B, Copyright (c) Alibaba Cloud. Licensed under Apache-2.0.\n' + b'Qwen3-Embedding-0.6B, Copyright (c) Alibaba Cloud. Licensed under Apache-2.0; see ' + b'freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt.\nhttps://huggingface.co/Qwen/Qwen3-Embedding-0.6B\n' b'Silero VAD, Copyright (c) 2020-present Silero Team. Licensed under the MIT License; see ' b'freevideo_engine/licenses/Silero-VAD-MIT.txt.\nhttps://github.com/snakers4/silero-vad\n') NOTICES = {'image': ('NOTICE.txt', NOTICE_TEXT), 'segment': ('SAM2-NOTICE.txt', SEGMENT_NOTICE_TEXT), diff --git a/freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt b/freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt new file mode 100644 index 00000000..1add3429 --- /dev/null +++ b/freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt @@ -0,0 +1,204 @@ +Qwen3-Embedding-0.6B (https://huggingface.co/Qwen/Qwen3-Embedding-0.6B) +Copyright (c) Alibaba Cloud. Licensed under the Apache License, Version 2.0. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 138e66bc99fa2887cf99661c423fba083d252d3c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 05/26] Show stale prompt rewrites as neutral notices --- web/prompt_enhance.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 9d74d6e8..0b3f9727 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -186,7 +186,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText context_length: t('The scene is too long for this model. Shorten it or use the original.', '内容超出改写模型容量,请精简或使用原文。'), out_of_memory: t('Not enough memory to rewrite. Close other GPU apps or use the original.', '改写时内存不足,请关闭其他显卡程序或使用原文。'), timeout: t('Rewriting took too long. Retry or use the original.', '改写超时,请重试或使用原文。'), - stale: t('The prompt or references changed. Rewrite again for the new scene.', '原文或素材已改变,请按新内容重新改写。'), + stale: t('The prompt or references changed. Rewrite again to use the current scene.', '原文或素材已更改,可按当前内容重新改写。'), cancelled: t('Cancelled. Your original prompt is retained.', '已取消,原文已保留。'), worker_cleanup_failed: t('The rewrite process could not close. Restart FreeVideo before generating.', '改写进程未能退出,请重启 FreeVideo 后再生成。'), }[error?.message] || t(`Could not enhance the prompt${reasonOf(error) ? `: ${reasonOf(error)}` : ''}. Retry or switch enhancement off to use the original.`, @@ -389,9 +389,11 @@ export function createPromptEnhancer({node, input, editor, api, context, setText redo.onclick = () => enhance().catch(() => {}); show(''); save(false); render(); function useOriginal(error) { - console.warn('FreeVideo prompt rewrite failed; using the original prompt.', error?.message); + // A successful rewrite discarded after an edit is a draft notice, not a failure. + const stale = error?.message === 'stale'; + if (!stale) console.warn('FreeVideo prompt rewrite failed; using the original prompt.', error?.message); versions.value.selected = 'original'; apply(); - show(t(`Generated with the original prompt. ${fallbackText(error)}`, `已用原文生成。${fallbackText(error)}`), 'note'); + show(t(`Generated with the original prompt. ${stale ? errorText(error) : fallbackText(error)}`, `已用原文生成。${stale ? errorText(error) : fallbackText(error)}`), 'note'); } return {element, model: {read: readModel, download: downloadModel, subscribe(listener) { modelListeners.add(listener); listener(modelState); From 7d50461950f779a2382ad0c10074e3dbe48ac0e1 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 06/26] Continue in pageable memory when a copy through a locked residual or attention readout buffer is... Continue in pageable memory when a copy through a locked residual or attention readout buffer is refused --- freevideo_engine/activation_staging.py | 74 +++++++++++++++++++++++++- freevideo_engine/decode_resume.py | 2 +- freevideo_engine/head_chunk.py | 16 ++++-- freevideo_engine/pass_configuration.py | 21 ++++++-- freevideo_engine/residual.py | 39 ++++++++++++-- freevideo_engine/runtime.py | 3 ++ freevideo_engine/two_pass_metrics.py | 1 + 7 files changed, 142 insertions(+), 14 deletions(-) diff --git a/freevideo_engine/activation_staging.py b/freevideo_engine/activation_staging.py index 9d2f2cd1..48c8c09d 100644 --- a/freevideo_engine/activation_staging.py +++ b/freevideo_engine/activation_staging.py @@ -1,6 +1,75 @@ """Contiguous D2H head groups with double-buffered CPU projection tiles.""" +import collections import torch +# Every refused locked-page copy of this process, residual stream and head +# readouts alike, so a report shows whether the fallback fired, which copy was +# refused and what CUDA said. The newest entries are kept; the count is exact. +_REFUSED = collections.deque(maxlen=32) +_refused_total = 0 + + +def record_refusal(source, copy, error): + global _refused_total + lines = str(error).strip().splitlines() + _REFUSED.append(dict(source=source, copy=copy, error=(lines[0] if lines else '')[:200])) + _refused_total += 1 + + +def refusals_mark(): + return _refused_total + + +def refusals_since(mark): + """Refusals recorded after `mark`, as plain dicts for the sampling metrics.""" + count = min(_refused_total - mark, len(_REFUSED)) + return [dict(row) for row in list(_REFUSED)[len(_REFUSED) - count:]] if count > 0 else [] + + +class LockedCopies: + """Asynchronous copies through page-locked buffers that continue in pageable memory. + + Windows can refuse a queued copy with "invalid argument" when the system + takes locked pages back. The queued work is finished first, so a sticky + error is raised as itself rather than hidden by the retry. The copy then + goes through a pageable temporary, which holds the same bytes, and the + remaining copies of these buffers skip the locked path. A failed device + allocation is not a refusal: it is raised for the caller to recover from, + and the buffers stay locked. + """ + + def __init__(self): + self.locked = True + self.fallbacks = 0 + + def _leave(self, device, copy, error): + record_refusal('readout', copy, error) + if device.type == 'cuda': + torch.cuda.synchronize(device) + self.locked = False + self.fallbacks += 1 + + def to_host(self, destination, source): + if self.locked: + try: + destination.copy_(source, non_blocking=True) + return + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + self._leave(source.device, 'to_host', error) + destination.copy_(source.to('cpu')) + + def to_device(self, source, device): + if self.locked: + try: + return source.to(device, non_blocking=True) + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + self._leave(torch.device(device), 'to_device', error) + return source.clone().to(device) + class HeadReadouts: def __init__(self, rows, head_widths, dtype, tile_rows): @@ -11,6 +80,7 @@ def __init__(self, rows, head_widths, dtype, tile_rows): self.groups = [torch.empty((self.capacity, width), dtype=dtype, pin_memory=True) for width in head_widths] self.tiles = [torch.empty((tile_rows, sum(head_widths)), dtype=dtype, pin_memory=True) for _ in range(2)] self.events = [None, None] + self.copies = LockedCopies() def supports(self, rows, head_widths, dtype, tile_rows): return rows <= self.capacity and tuple(head_widths) == self.widths and dtype == self.dtype and tile_rows == self.tile_rows @@ -18,7 +88,7 @@ def supports(self, rows, head_widths, dtype, tile_rows): def store(self, group, values): if values.shape[1] != self.widths[group] or not values.is_contiguous(): raise ValueError('Head readouts must be contiguous [rows, head channels]') - self.groups[group][:len(values)].copy_(values, non_blocking=True) + self.copies.to_host(self.groups[group][:len(values)], values) def project(self, module, output, rows): # The caller synchronizes once after all D2H groups have been written. @@ -33,7 +103,7 @@ def project(self, module, output, rows): for group, width in zip(self.groups, self.widths): tile[:, offset:offset + width].copy_(group[start:start + count]) offset += width - device_values = tile.to(output.device, non_blocking=True) + device_values = self.copies.to_device(tile, output.device) done = torch.cuda.Event() done.record(stream) self.events[slot] = done diff --git a/freevideo_engine/decode_resume.py b/freevideo_engine/decode_resume.py index 635497ff..1f2eeeb0 100644 --- a/freevideo_engine/decode_resume.py +++ b/freevideo_engine/decode_resume.py @@ -20,7 +20,7 @@ COMPUTE_PARTITION = frozenset(('head_chunk', 'window_batch', 'ff_chunk', 'projection_chunk', 'head_parallelism', 'attention_cpu_outputs', 'grouped_attention_outputs', 'fp8_ff_recompute', 'residual_offload', 'query_chunk')) SAMPLE_FIELDS = ('config', 'sample_seconds', 'step_seconds', 'finite_latents', 'geometry', - 'conditioning_info', 'conditioning_shape', 'offload', 'residual_offload_steps', + 'conditioning_info', 'conditioning_shape', 'offload', 'residual_offload_steps', 'locked_copy_refusals', 'attention_backend_calls', 'head_execution', 'text_refinement', 'sampling_memory', 'load_seconds', 'load_breakdown', 'load_peak_allocated_bytes', 'load_peak_reserved_bytes', 'torch_peak_allocated_bytes', 'torch_peak_reserved_bytes', 'latent_save_seconds', 'sample_metrics_scope', diff --git a/freevideo_engine/head_chunk.py b/freevideo_engine/head_chunk.py index ccf61b89..246a06bd 100644 --- a/freevideo_engine/head_chunk.py +++ b/freevideo_engine/head_chunk.py @@ -59,7 +59,7 @@ def release_readouts(policy): a 16 GiB machine failed cudaHostAlloc at the refine pass's first layer. """ held = [getattr(policy, name, None) for name in ('group_readouts', 'host_outputs')] - for name in ('group_readouts', 'host_outputs', 'host_output_key'): + for name in ('group_readouts', 'host_outputs', 'host_output_key', 'host_copies'): if hasattr(policy, name): delattr(policy, name) if any(value is not None for value in held): @@ -135,12 +135,14 @@ def forward(attn, x, rotary): for rows in (len(x), linear_shape[0])) soft, linear = policy.group_readouts elif cpu_outputs: + from .activation_staging import LockedCopies key = (soft_shape, linear_shape, x.dtype) if getattr(policy, 'host_output_key', None) != key: release_readouts(policy) policy.host_outputs = (torch.empty(soft_shape, dtype=x.dtype, pin_memory=True), torch.empty(linear_shape, dtype=x.dtype, pin_memory=True)) policy.host_output_key = key + policy.host_copies = LockedCopies() soft, linear = policy.host_outputs elif reuses_input(attn, x, projections): # Measured second-pass peak: the head groups, with x, both output planes, @@ -201,8 +203,10 @@ def group(start): soft.store(start // chunk, attended.flatten(1)) elif taken: soft.write(channels, attended.flatten(1)) + elif cpu_outputs: + policy.host_copies.to_host(soft[:, channels], attended.flatten(1)) else: - soft[:, channels].copy_(attended.flatten(1), non_blocking=cpu_outputs) + soft[:, channels].copy_(attended.flatten(1)) del attended if not taken: beta = torch.sigmoid(F.linear(x[video], branch.beta_proj.weight[heads])) @@ -221,8 +225,10 @@ def group(start): linear_scale.update(readout) if grouped_outputs: linear.store(start // chunk, readout.contiguous()) + elif cpu_outputs: + policy.host_copies.to_host(linear[:, linear_channels], readout) else: - linear[:, linear_channels].copy_(readout, non_blocking=cpu_outputs) + linear[:, linear_channels].copy_(readout) del raw, readout, gate, beta, text_beta # Keep the exact projection/head/window shapes. Merely widening the @@ -293,13 +299,13 @@ def group(start): out = x.new_empty((len(x), output_width)) for row in range(0, len(x), projection_chunk): rows = slice(row, row + projection_chunk) - part = soft[rows].to(x.device, non_blocking=True) + part = policy.host_copies.to_device(soft[rows], x.device) scale = row_scale(soft_scale, rows) if soft_scale is not None else None out[rows] = orig.to_out[1](project_with_scale(orig.to_out[0], part, scale)) del part for row in range(0, len(linear), projection_chunk): rows = slice(row, row + projection_chunk) - part = linear[rows].to(x.device, non_blocking=True) + part = policy.host_copies.to_device(linear[rows], x.device) scale = row_scale(linear_scale, rows) if linear_scale is not None else None out[video][rows].add_(project_with_scale(attn.to_out_linear, part, scale)) del part diff --git a/freevideo_engine/pass_configuration.py b/freevideo_engine/pass_configuration.py index 1785e6bc..191bf539 100644 --- a/freevideo_engine/pass_configuration.py +++ b/freevideo_engine/pass_configuration.py @@ -1,4 +1,5 @@ """Change pass-local compute tiling while preserving loaded model weights.""" +import sys from contextlib import contextmanager @@ -62,6 +63,20 @@ def configuration(engine, selected): attention.current_plan = attention.offsets = attention.batches = None # Canvas-specific host buffers must not survive alongside the next # pass's differently shaped buffers. Immutable weights remain untouched. - for name in ('host_outputs', 'host_output_key', 'group_readouts'): - if hasattr(attention, name): - delattr(attention, name) + # Dropping them here without waiting for the device returned locked + # blocks that the pass's last copies could still be using, and the + # next pass's release then found nothing left to wait for. + try: + from .head_chunk import release_readouts + except ModuleNotFoundError: + # No torch, so no page-locked readouts can exist to return. + def release_readouts(policy): + for name in ('group_readouts', 'host_outputs', 'host_output_key', 'host_copies'): + if hasattr(policy, name): + delattr(policy, name) + pending = sys.exc_info()[0] + try: + release_readouts(attention) + except RuntimeError: + if pending is None: + raise diff --git a/freevideo_engine/residual.py b/freevideo_engine/residual.py index 17cc9a1e..64b2ac51 100644 --- a/freevideo_engine/residual.py +++ b/freevideo_engine/residual.py @@ -21,6 +21,7 @@ def __init__(self, tensor): self.stored = False self.closed = False self.copies = {'to_host': 0, 'to_device': 0} + self.pinned_fallbacks = 0 self.copy_wall_seconds = 0. # Set by the block loop: collect reference cycles once this block has # dropped its input (packing.install_streamed_forward). @@ -43,7 +44,15 @@ def store(self, tensor): tick = time.perf_counter() if self.host is None: self.host = self._allocate() - self.host.copy_(tensor, non_blocking=self.pinned) + try: + self.host.copy_(tensor, non_blocking=self.pinned) + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + if not self.pinned: + raise + self._leave_locked_pages(keep=False, error=error) + self.host.copy_(tensor) self.stored = True self.copies['to_host'] += 1 self.copy_wall_seconds += time.perf_counter()-tick @@ -70,12 +79,36 @@ def _allocate(self): return host return torch.empty(self.shape, dtype=self.dtype, device='cpu') + def _leave_locked_pages(self, keep, error): + # A copy through the locked buffer was refused (Windows can fail a + # queued copy with "invalid argument" when the system takes pages back). + # Finish the queued work first, so a sticky error is raised as itself + # rather than hidden by the retry, then continue in pageable memory, + # which holds the same bytes and is read back unchanged. + from .activation_staging import record_refusal + record_refusal('residual', 'to_device' if keep else 'to_host', error) + if self.device.type == 'cuda': + torch.cuda.synchronize(self.device) + host = torch.empty(self.shape, dtype=self.dtype, device='cpu') + if keep: + host.copy_(self.host) + self.host, self.pinned = host, False + self.pinned_fallbacks += 1 + def restore(self): if self.closed or not self.stored: raise RuntimeError('No complete host residual is available') tick = time.perf_counter() # copy=True also makes CPU contract checks use independent storage. - tensor = self.host.to(self.device, copy=True, non_blocking=self.pinned) + try: + tensor = self.host.to(self.device, copy=True, non_blocking=self.pinned) + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + if not self.pinned: + raise + self._leave_locked_pages(keep=True, error=error) + tensor = self.host.to(self.device, copy=True) self.copies['to_device'] += 1 self.copy_wall_seconds += time.perf_counter()-tick return tensor @@ -89,7 +122,7 @@ def replace(self, tensor): def stats(self): host_bytes = self.host.numel()*self.host.element_size() if self.host is not None else 0 return dict(host_buffer_bytes=host_bytes, pinned_host_bytes=host_bytes if self.pinned else 0, - copies=dict(self.copies), blocking_copy_wall_seconds=self.copy_wall_seconds, + copies=dict(self.copies), pinned_fallbacks=self.pinned_fallbacks, blocking_copy_wall_seconds=self.copy_wall_seconds, timing_scope=('Locked-page copies are queued asynchronously; this is the time spent issuing them.' if self.pinned else 'Blocking copy calls can include waiting for prior compute; not pure PCIe transfer time.')) diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index 44bb57aa..34f796ec 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -809,6 +809,8 @@ def append(self, value): pass_cache = dict(gpu_budget_bytes=pass_cache_budget_bytes, admitted_bytes=0, requested_resident_blocks=pass_resident_blocks) residual_steps = [] + from .activation_staging import refusals_mark, refusals_since + refusals_before = refusals_mark() attention_before = dict(self.attention.backend_calls) if self.attention is not None else None gemm_before = None if self.config.get('fp8_gemm'): @@ -880,6 +882,7 @@ def append(self, value): # weights through VAE loading. Reports own plain numbers only. sampled = {'sample_seconds': time.perf_counter() - started, 'step_seconds': list(step_seconds), 'offload': stats, 'residual_offload_steps': residual_steps, + 'locked_copy_refusals': refusals_since(refusals_before), 'pass_cache_admission': pass_cache, 'compute_configuration': {key: planned.get(key) for key in ('head_chunk', 'window_batch', 'ff_chunk', 'projection_chunk', 'prefetch', diff --git a/freevideo_engine/two_pass_metrics.py b/freevideo_engine/two_pass_metrics.py index a9ff1eb0..daa7074e 100644 --- a/freevideo_engine/two_pass_metrics.py +++ b/freevideo_engine/two_pass_metrics.py @@ -29,6 +29,7 @@ def combine(first, second, upscale, sampling_plan): result[key] = ({name: sum(row.get(name, 0) for row in rows) for name in set().union(*(row.keys() for row in rows))} if rows else None) result['residual_offload_steps'] = list(first.get('residual_offload_steps', [])) + list(second.get('residual_offload_steps', [])) + result['locked_copy_refusals'] = list(first.get('locked_copy_refusals', [])) + list(second.get('locked_copy_refusals', [])) if first.get('head_execution') and second.get('head_execution'): result['head_execution'] = dict(second['head_execution']) for name in ('parallel_head_calls', 'parallel_head_warmups'): From 38d59723323a1f78edd7bda1d4a116078ed184e8 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 07/26] Reserve non-local room for the locked residual buffer of large requests --- freevideo_engine/adaptive.py | 6 +++++- freevideo_engine/runtime.py | 5 +++-- freevideo_engine/windows_gpu_memory.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index 8a371cd7..9963b8ef 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -363,6 +363,9 @@ def nonlocal_exhausted(failure): Page-locked host memory counts against that budget. A 20 s request failed with non-local usage at 13.14 of 13.24 GiB, 10.68 GiB of VRAM free and no caching-allocator OOM; the failed allocation itself is not in the usage. + A 1920x1088 request failed with usage at 13.9 of 15.2 GiB: the allocation + that did not fit was the 2 GiB residual buffer, so the margin is the pin + reserve rather than one GiB. """ gpu = failure.get('gpu') if isinstance(failure.get('gpu'), dict) else {} values = [gpu.get(key) for key in ('nonlocal_budget_bytes', 'nonlocal_usage_bytes', @@ -370,7 +373,8 @@ def nonlocal_exhausted(failure): if not all(type(value) is int and value >= 0 for value in values) or not values[0]: return False budget, usage, local_budget, local_usage = values - return (usage >= budget - 2**30 and local_usage <= local_budget - 2 * 2**30 + from .windows_gpu_memory import NONLOCAL_PIN_RESERVE + return (usage >= budget - NONLOCAL_PIN_RESERVE and local_usage <= local_budget - 2 * 2**30 and not gpu.get('allocator_ooms')) diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index 34f796ec..49226a75 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -162,10 +162,11 @@ def __init__(self, cache, *, base=None, checkpoint=None, validate_catalog(manifest, len(model.transformer_blocks)) # Locked buffers allocated after the weights must also fit the Windows # non-local budget: CPU attention readouts grow with this request's rows. - from .windows_gpu_memory import NONLOCAL_PIN_RESERVE, nonlocal_readout_bytes + from .windows_gpu_memory import NONLOCAL_PIN_RESERVE, nonlocal_readout_bytes, nonlocal_residual_bytes readouts = (nonlocal_readout_bytes(self.canvas, model.config.num_attention_heads * model.config.attention_head_dim) if attention_cpu_outputs else 0) - self.nonlocal_reserve_bytes = NONLOCAL_PIN_RESERVE + readouts + self.nonlocal_reserve_bytes = NONLOCAL_PIN_RESERVE + readouts + ( + nonlocal_residual_bytes(residual_host_headroom(self.canvas)) if residual_offload else 0) # RAM views fill during the first pass; leave what the refine pass will # still allocate. A 20 s request that let views take that room drove # physical availability to 0.01 GiB when its full-size readouts arrived. diff --git a/freevideo_engine/windows_gpu_memory.py b/freevideo_engine/windows_gpu_memory.py index 8786df34..ec35807d 100644 --- a/freevideo_engine/windows_gpu_memory.py +++ b/freevideo_engine/windows_gpu_memory.py @@ -117,6 +117,20 @@ def nonlocal_readout_bytes(canvas, attention_width, text_rows=1024): return int(1.25 * (rows + video) * attention_width * 2) +def nonlocal_residual_bytes(headroom_bytes): + """Non-local room the locked residual staging buffer needs beyond the base reserve. + + The base reserve was sized with the 1 GiB residual of a 72,576-row request. + A 1920x1088 request (147,721 rows, 2.04 GiB residual) on a 31.9 GB machine + pinned weights up to the base reserve, then failed with + cudaErrorMemoryAllocation at the first full-resolution step with non-local + usage at 13.9 of 15.2 GiB: the residual plus the transfer slots no longer + fit. Larger requests need the extra room, with half again for the + slots and driver allocations that grow beside it. + """ + return int(1.5 * max(0, headroom_bytes - 2**30)) + + def nonlocal_pin_capacity(reserve=NONLOCAL_PIN_RESERVE): """Bytes this process may still page-lock inside its non-local budget, or None if unreadable.""" reader = None From 81eafd169bb6611047db8977eea29a34754d9f1f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 08/26] Prompt enhancement names the prompt length and the next step when it is over the limit --- web/prompt_enhance.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 0b3f9727..6e433f7d 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -78,6 +78,9 @@ export function promptPrestart(api, nodeId, enabled, alive = () => true) { }}; } +// The longest prompt the rewrite service accepts (freevideo_engine/prompt_vlm/rules.py). +const PROMPT_LIMIT = 12000; + // An empty box is never rewritten, the untouched starter prompt included: // enhancement waits for the user's own scene. // `prepare` turns the user's text into what the model reads (character mentions written out with their pictures); @@ -161,6 +164,12 @@ export function createPromptEnhancer({node, input, editor, api, context, setText // Available RAM the model needs; the server knows the usual case for this card. let ramBytes = 11 * 2 ** 30; const gib = value => String(Math.round(value / 2 ** 30)); + // Mentions are counted as the character descriptions they expand to, so the count can exceed what the box shows. + const lengthOf = (error, english) => { + const number = error.characters.toLocaleString('en-US'); + return english ? `The prompt${error.expanded ? ' and character descriptions' : ''} ha${error.expanded ? 've' : 's'} ${number} characters` + : `提示词${error.expanded ? '连同角色描述' : ''}共 ${number} 字符`; + }; // Unknown codes keep the server's own reason, without paths, so the user can quote it. const reasonOf = error => String(error?.message || '').replace(/[A-Za-z]:[\\/][^\s'"]*|\/[^\s'"]+\/[^\s'"]*/g, '').replace(/\s+/g, ' ').trim().slice(0, 120); // A dropped request names a browser extension only when the error carries evidence of one. @@ -181,7 +190,9 @@ export function createPromptEnhancer({node, input, editor, api, context, setText audio_media: t('Enhancement cannot read audio. Add images or videos in Media, or use the original.', '增强读不到音频,请在素材中添加图片或视频,或使用原文。'), unreadable_video: t('Could not read a frame from this video. Replace it with another video or an image, or use the original.', '读不出这个视频的画面,请换一个视频或图片,或使用原文。'), too_many_images: t('Use up to 8 reference images for enhancement.', '增强暂支持最多 8 张参考图。'), - invalid_prompt: t('Enter a prompt of up to 12,000 characters.', '请输入不超过 12,000 字符的提示词。'), + invalid_prompt: error?.characters + ? t(`${lengthOf(error, true)}; enhancement supports up to 12,000. Shorten it or use the original.`, `${lengthOf(error, false)},增强最多支持 12,000 字符,请精简或使用原文。`) + : t('Enter a prompt of up to 12,000 characters.', '请输入不超过 12,000 字符的提示词。'), invalid_output: t('The rewrite was incomplete. Retry or use the original.', '改写结果不完整,请重试或使用原文。'), context_length: t('The scene is too long for this model. Shorten it or use the original.', '内容超出改写模型容量,请精简或使用原文。'), out_of_memory: t('Not enough memory to rewrite. Close other GPU apps or use the original.', '改写时内存不足,请关闭其他显卡程序或使用原文。'), @@ -199,7 +210,9 @@ export function createPromptEnhancer({node, input, editor, api, context, setText download_failed: t('The optional model download paused. Retry to resume.', '可选模型下载已暂停,重试可继续下载。'), disk_space: t('Not enough disk space for the optional model.', '磁盘空间不足,无法安装可选模型。'), ram_space: t(`The rewrite model needs about ${gib(error?.ramBytes || ramBytes)} GiB of available RAM to load.`, `改写模型加载需约 ${gib(error?.ramBytes || ramBytes)} GiB 可用内存。`), - invalid_prompt: t('Rewriting supports prompts of up to 12,000 characters.', '改写只支持不超过 12,000 字符的提示词。'), + invalid_prompt: error?.characters + ? t(`${lengthOf(error, true)}; rewriting supports up to 12,000. Shorten it before rewriting again.`, `${lengthOf(error, false)},改写最多支持 12,000 字符,精简后可再改写。`) + : t('Rewriting supports prompts of up to 12,000 characters.', '改写只支持不超过 12,000 字符的提示词。'), invalid_output: t('The rewrite was incomplete.', '改写结果不完整。'), context_length: t('The scene is too long for the rewrite model.', '内容超出改写模型容量。'), out_of_memory: t('Not enough memory to rewrite. Close other GPU apps before rewriting again.', '改写时内存不足,关闭其他显卡程序后可再改写。'), @@ -361,6 +374,9 @@ export function createPromptEnhancer({node, input, editor, api, context, setText 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; + // The server counts what it receives, which includes the character descriptions the mentions are written out as. + const text = prepare(original), characters = [...text].length; + if (characters > PROMPT_LIMIT) throw Object.assign(new Error('invalid_prompt'), {characters, expanded: text !== original}); const info = await readModel(); if (disposed || cancelling) throw new Error('cancelled'); if (!await installModel(info)) { @@ -368,7 +384,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText versions.select('original'); apply(); throw new Error('cancelled'); } - const result = await poll('rewrite', {text: prepare(original), ...scene}); + const result = await poll('rewrite', {text, ...scene}); if (disposed || cancelling) throw new Error('cancelled'); const restored = restore(result.text); if (edits !== versions.value.edits || !versions.accept(key, context(), display(restored), restored)) throw new Error('stale'); From 34a8f1cf32e1d12eb7afb06f4910daad5cbc8852 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 09/26] Warn when other programs worsen generation memory placement --- freevideo_engine/agent/comfy.py | 3 +- freevideo_engine/agent/errors.py | 30 +++- freevideo_engine/agent/jobs.py | 16 +- freevideo_engine/agent/schema.py | 3 + freevideo_engine/comfy_capabilities.py | 69 ++++++- freevideo_engine/image_gen/failure.py | 9 + freevideo_engine/image_gen/service.py | 5 +- freevideo_engine/other_usage.py | 238 +++++++++++++++++++++++++ web/cast_generate.css | 1 + web/cast_generate.js | 14 +- web/error_panel.js | 12 +- web/image_editor.css | 1 + web/image_generation.js | 22 ++- web/other_usage.js | 66 +++++++ web/studio.css | 1 + web/studio.js | 16 +- 16 files changed, 482 insertions(+), 24 deletions(-) create mode 100644 freevideo_engine/other_usage.py create mode 100644 web/other_usage.js diff --git a/freevideo_engine/agent/comfy.py b/freevideo_engine/agent/comfy.py index d9afb865..b27f6317 100644 --- a/freevideo_engine/agent/comfy.py +++ b/freevideo_engine/agent/comfy.py @@ -354,7 +354,8 @@ def image_start(self, action, value): 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 + kind='image', report_id=getattr(error, 'report_id', None), + other_usage=getattr(error, 'status', {}).get('other_usage')) 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 97d362e2..6c03fe6b 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -12,6 +12,7 @@ REQUEST_ID = ContextVar('agent_request_id', default=None) # The work session a token request belongs to (sessions.py); jobs and productions record it. SESSION = ContextVar('agent_session', default=None) +_UNREAD_USAGE = object() # The operation digest a confirmation approves: method, route, path values and body without ref. CONFIRM = ContextVar('agent_confirm', default=None) # code: HTTP status, retryable, public message, concrete recovery action @@ -128,6 +129,8 @@ # Static bilingual image reasons; backend exception strings never enter the API. IMAGE_REASONS = { + 'small_memory': ('This Mac has less than 16 GB of memory and cannot generate pictures.', + '这台 Mac 的内存不足 16 GB,无法生成图片。'), 'no_memory': ('Not enough GPU memory, even at the smallest size. Close other apps using the GPU, then try again.', '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。'), 'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'), @@ -164,6 +167,7 @@ def image_failure(code, report_id=None): # Recovery is a function of the committed task and phase, not just an error code. IMAGE_ERRORS = { **LORA_MESSAGE_EN, + 'small_memory': IMAGE_REASONS['small_memory'][0], 'invalid_request': 'The picture request contains invalid fields.', 'invalid_prompt': 'The picture prompt must contain text and be at most 2000 characters.', 'invalid_dimensions': 'Picture width and height must be integers from 256 to 2048.', @@ -413,7 +417,26 @@ def failure_reason_en(error): def reasons(error): - return dict(error_zh=failure_reason(error), error_en=failure_reason_en(error)) + from ..other_usage import fact, join + result = dict(error_zh=failure_reason(error), error_en=failure_reason_en(error)) + for key, zh in (('error_zh', True), ('error_en', False)): + suffix = fact(error.get('other_usage'), zh=zh) + if suffix and suffix not in result[key]: + result[key] = join(result[key], suffix, zh=zh) + return result + + +def capture_other_usage(error): + from ..other_usage import at_failure, fact, join, MEMORY_ERRORS + if error and error.get('code') in MEMORY_ERRORS and 'other_usage' not in error: + error = dict(error, other_usage=at_failure(error['code'])) + if error and error.get('other_usage') and error.get('reason'): + error = dict(error, reason=dict(error['reason'])) + for lang in ('en', 'zh'): + suffix = fact(error['other_usage'], zh=lang == 'zh') + if suffix and suffix not in error['reason'].get(lang, ''): + error['reason'][lang] = join(error['reason'].get(lang, ''), suffix, zh=lang == 'zh') + return error def with_reason(error): @@ -422,7 +445,7 @@ def with_reason(error): 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, - report_id=None, memory=None): + report_id=None, memory=None, other_usage=_UNREAD_USAGE): code = code if code in ERRORS else 'internal_error' _, retryable, default_message, _ = ERRORS[code] action = recovery(code, kind=kind, stage=stage, resumable=resumable) @@ -438,6 +461,9 @@ def build(code, *, details=None, request=None, validation=False, message=None, n result.update(image_failure(code, report_id)) if code == 'no_memory' and memory == 'host': result['memory'] = 'host' + if other_usage is not _UNREAD_USAGE: + result['other_usage'] = other_usage + result = capture_other_usage(result) if action_required: result['next'] = next_action if validation and next_action else action if code == 'busy': diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 51648bbe..9cd7488e 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -88,7 +88,8 @@ 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'), - kind=self.details.get('kind'), report_id=self.details.get('report_id')) + kind=self.details.get('kind'), report_id=self.details.get('report_id'), + **({'other_usage': self.details['other_usage']} if 'other_usage' in self.details else {})) class ManagedByProduction(JobError): @@ -681,16 +682,19 @@ def touch(self, row, **changes): def finish(self, row, status, *, result=None, error=None, save=True): if error is not None: + error = errors.capture_other_usage(error) code = error.get('code', 'internal_error') report_id = error.get('report_id') if code == 'invalid_json' and row['kind'] in ('vlm', 'enhance'): code = 'vlm_invalid_json' - reason_zh, reason_en = errors.failure_reason(dict(error, code=code)), errors.failure_reason_en(dict(error, code=code)) + failure_reasons = errors.reasons(dict(error, code=code)) + reason_zh, reason_en = failure_reasons['error_zh'], failure_reasons['error_en'] 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'), - report_id=error.get('report_id'), memory=error.get('memory')) + report_id=error.get('report_id'), memory=error.get('memory'), + other_usage=error.get('other_usage')) if row['kind'] == 'image': reason_zh, reason_en = error['error_zh'], error['error_en'] if error is not None: @@ -1235,7 +1239,8 @@ def view(self, row, *, detail=True): 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')) + action_required=result['error']['code'] != 'cancelled', report_id=row['error'].get('report_id'), + other_usage=row['error'].get('other_usage')) else: result['error'] = dict(result['error'], **errors.reasons(row['error'])) report_id = row['error'].get('report_id') @@ -2098,7 +2103,8 @@ def poll_image(self, row): code = failure.get('code') if isinstance(failure, dict) else failure code = code or 'generation_failed' if not self.wait_image(row, code): - self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory'))) + self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory'), + other_usage=state.get('other_usage'))) # Local VLM ----------------------------------------------------------------------------------------- def start_vlm(self, row): diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 57fa1ab4..905a4d0e 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -1538,6 +1538,9 @@ def openapi(): '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), + other_usage=nullable(obj(dict(vram_gib=number(0), ram_gib=number(0), + kinds={'type': 'array', 'items': enum(('gpu', 'ram'))}, signature=string()), + ('vram_gib', 'ram_gib', 'kinds', 'signature'))), error_zh=string(), error_en=string()), ('code', 'message', 'details', 'retryable', 'request_id', 'docs'))})) diff --git a/freevideo_engine/comfy_capabilities.py b/freevideo_engine/comfy_capabilities.py index 4ad9c07a..677c7b19 100644 --- a/freevideo_engine/comfy_capabilities.py +++ b/freevideo_engine/comfy_capabilities.py @@ -27,7 +27,7 @@ def _hash(value): ensure_ascii=False, allow_nan=False).encode()).hexdigest()[:12] -def _context(output_directory): +def _machine_context(): from .background_compile import compile_owed from .comfy_bridge import installation root, machine = installation() @@ -52,6 +52,12 @@ def _context(output_directory): machine['hardware'] = observed # Receipt read only: tells the page the first run still compiles. machine['compile_owed'] = compile_owed(root) + return machine + + +def _context(output_directory): + machine = _machine_context() + device = machine.get('device_identity') if machine.get('device_backend') == 'mps' else machine.get('gpu_uuid') # Do not inherit the sampling-estimate route's 15-second history cache: # reopening the grid after a generation must notice the new timing record. rows = local_records(output_directory, device, system=machine.get('system'), stage_details=True, refresh=True) @@ -270,6 +276,56 @@ def _query(query): return aspect, size, task, query.get('adapters') == '1', normalize_references(task, references) +def _usage_query(query): + """Same canvas/material fields as capabilities, plus this request's duration/profile.""" + from .geometry import geometry + from .tiers import TIERS, STEP_CACHES + kind = query.get('kind', 'video') + if kind not in ('video', 'image'): + raise ValueError('Unsupported generation kind.') + if kind == 'image': + width, height = int(query.get('width', 1024)), int(query.get('height', 1024)) + if not 256 <= width <= 2048 or not 256 <= height <= 2048: + raise ValueError('Use image dimensions from 256 to 2048.') + count = int(query.get('image_references', '0')) + if not 0 <= count <= 4: + raise ValueError('Use at most four image references.') + # Image preflight normalizes each reference to 1024**2 pixels. + return dict(kind=kind, width=width, height=height, reference_pixels=count * 1024**2) + _, size, task, _, references = _query(query) + width, height = size or (1344, 768) + tier = query.get('tier', 'light') + if tier not in (*TIERS, 'custom'): + raise ValueError('Unsupported tier.') + defaults = TIERS.get(tier, TIERS['light']) + flag = query.get('two_pass', '1' if defaults['two_pass'] else '0') + if flag not in ('0', '1'): + raise ValueError('Use a boolean two_pass flag.') + steps, refine = int(query.get('base_steps', defaults['steps'])), int(query.get('refine_steps', 3)) + cache = query.get('step_cache', defaults['step_cache']) + cache = None if cache in (None, 'none', '') else cache + upscale = float(query.get('upscale_factor', defaults['upscale'])) + if not 1 <= steps <= 32 or not 0 <= refine <= 31 or cache not in STEP_CACHES or upscale not in (1.2, 1.5, 2): + raise ValueError('Unsupported sampling profile.') + if 'frames' in query and 'seconds' in query: + raise ValueError('Specify frames or seconds, not both.') + shape = geometry(width, height, frames=int(query['frames'])) if 'frames' in query else geometry( + width, height, seconds=float(query.get('seconds', 10.125))) + return dict(kind=kind, width=width, height=height, frames=shape['frames'], steps=steps, + two_pass=flag == '1', task=task, refine=refine, references=reference_parameters(references), + step_cache=cache, upscale=upscale) + + +def _other_usage(params, failure=False): + from . import other_usage + try: + machine = _machine_context() + usage = other_usage.read(machine) + return other_usage.notice(other_usage.net(usage, machine)) if failure else other_usage.for_request(machine, usage, **params) + except Exception: + return None + + def register(): from aiohttp import web import folder_paths @@ -281,6 +337,17 @@ def register(): cache = None if mac else CapabilityCache(folder_paths.get_output_directory(), server) server._freevideo_capabilities = cache if cache is not None else True + @server.routes.get('/freevideo/other-usage') + async def other_usage(request): + result = None + if not mac: + try: + params = _usage_query(request.query) + except (ValueError, TypeError, OverflowError, RecursionError) as error: + raise web.HTTPBadRequest(text=str(error)) from error + result = await asyncio.to_thread(_other_usage, params, request.query.get('failure') == '1') + return web.json_response(dict(status='ok', notice=result), headers={'Cache-Control': 'no-store'}) + @server.routes.get('/freevideo/capabilities') async def capabilities(request): if mac: diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index 08823270..35c23c2a 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -66,6 +66,15 @@ def host_memory_shortage(error): return commit_exhausted(error) +def append_other_usage(result, machine): + from ..other_usage import at_failure, fact, join + value = at_failure(result.get('error'), machine) + result['other_usage'] = value + if value and result.get('message_zh'): + result['message_zh'] = join(result['message_zh'], fact(value, zh=True), zh=True) + return result + + class FailureError(ValueError): """Admission rejection with the same safe status as the HTTP reply.""" def __init__(self, status): diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 36d1cd21..70142767 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -763,6 +763,9 @@ def failed(self, code, context, *, row=None, error=None, video_busy=None): if code == 'no_memory' and (isinstance(row, dict) and row.get('memory') == 'host' or error is not None and host_memory_shortage(error)): result.update(memory='host', message_zh=HOST_MESSAGE_ZH) + if code in ('no_memory', 'small_memory'): + from .failure import append_other_usage + append_other_usage(result, context.machine) if code not in NO_REPORT: if video_busy is None: try: @@ -1287,7 +1290,7 @@ async def action(request): raise ValueError('invalid_request') result = await asyncio.to_thread(service.history_open) else: - result = service.start(name, value) + result = await asyncio.to_thread(service.start, name, value) return web.json_response(result) except FailureError as error: return web.json_response(error.status, status=400) diff --git a/freevideo_engine/other_usage.py b/freevideo_engine/other_usage.py new file mode 100644 index 00000000..ca39caee --- /dev/null +++ b/freevideo_engine/other_usage.py @@ -0,0 +1,238 @@ +"""Best-effort, anonymous occupancy and its effect on this request's placement. + +No process names, model loads or CUDA initialization. Call readings off the +event loop; an incomplete reading never becomes a claim about other programs. +""" +import os +import platform +import sys + +from .background_room import Gpu, available_memory, family +from .capabilities import IDLE_IN_USE, _admit, _machine +from .hardware import GiB +from .system import system_memory + +THRESHOLD = GiB +CONTEXT_BYTES = GiB // 2 +# What the system and desktop use with no other program running. Readings are +# whole-machine use minus this program's own, so they include it; only the part +# above it counts as other programs. Host memory gets a generous allowance so +# the notice stays quiet unless something else is clearly running. +SYSTEM_RAM = {'Windows': 6 * GiB, 'Linux': 2 * GiB} +MEMORY_ERRORS = frozenset(('no_memory', 'small_memory', 'insufficient_memory', + 'out_of_memory', 'ram_space')) + + +def _mac(machine): + return (platform.system() == 'Darwin' or machine.get('device_backend') == 'mps' + or (machine.get('device_identity') or {}).get('backend') == 'mps' + or (machine.get('hardware') or {}).get('system') == 'Darwin') + + +def _running(): + """Unknown activity is unsafe for the WDDM allocator approximation.""" + from .resident_process import OWNER + from .background_compile import COMPILE + if OWNER.active or COMPILE.process is not None and COMPILE.process.poll() is None: + return True + module = sys.modules.get('server') + server = module.PromptServer.instance if module is not None else None + if server is not None: + service = getattr(server, '_freevideo_image_gen', None) + if service is not None and service.busy(): + return True + queue = getattr(server, 'prompt_queue', None) + if queue is not None and queue.get_current_queue()[0]: + return True + return False + + +def _allocator_credit(pids): + """WDDM has no per-process bytes. Only trust idle, already loaded allocators. + + A child with a persistent pool cannot be accounted for by the parent's + torch allocator. Suppress the approximation while such a worker lives. + """ + if _running(): + return None + from .resident_process import OWNER + module = sys.modules.get('server') + server = module.PromptServer.instance if module is not None else None + service = getattr(server, '_freevideo_image_gen', None) + for process in (OWNER.process, getattr(service, 'process', None)): + if process is not None and process.poll() is None: + return None + torch = sys.modules.get('torch') + if torch is None or not torch.cuda.is_initialized(): + return None + return int(torch.cuda.memory_reserved()) + CONTEXT_BYTES + + +def _rss(pids): + import psutil + total = 0 + for pid in pids: + try: + total += psutil.Process(pid).memory_info().rss + except psutil.NoSuchProcess: + continue # A child that exited holds no memory now. + return total + + +def read(machine=None): + """{'vram': bytes, 'ram': bytes}, or None if either cannot be established.""" + gpu = None + try: + if platform.system() == 'Darwin': + return None + if machine is None: + from .comfy_bridge import installation + _, machine = installation() + if (_mac(machine) or machine.get('device_backend', 'cuda') != 'cuda' + or machine.get('gpu_uuid') is None): + return None + pids = family(os.getpid()) + gpu = Gpu(machine.get('gpu_uuid')) + memory = gpu.memory() + if memory is None: + return None + used, total = memory + if not total or not 0 <= used <= total: + return None + rows = gpu.processes() + if rows is None or any(size is None for size in rows.values()): + if platform.system() != 'Windows': + return None + own = _allocator_credit(pids) + if own is None: + return None + else: + own = sum(size for pid, size in rows.items() if pid in pids) + host_total, available = system_memory()['total_bytes'], available_memory() + if host_total <= 0 or not 0 <= available <= host_total: + return None + return dict(vram=max(0, used - own), ram=max(0, host_total - available - _rss(pids))) + except Exception: + return None + finally: + if gpu is not None: + try: + gpu.close() + except Exception: + pass + + +def net(usage, machine): + """The reading above what the system and desktop use by themselves.""" + if not isinstance(usage, dict) or not isinstance(machine, dict): + return None + hardware = machine.get('hardware') or {} + system = hardware.get('system') or machine.get('system') + if system not in SYSTEM_RAM or any(type(usage.get(k)) is not int or usage[k] < 0 for k in ('vram', 'ram')): + return None + return dict(vram=max(0, usage['vram'] - IDLE_IN_USE[system]['vram']), + ram=max(0, usage['ram'] - SYSTEM_RAM[system])) + + +def notice(usage): + """Only anonymous numbers cross the UI boundary; sub-GiB noise stays quiet.""" + if not isinstance(usage, dict) or any(type(usage.get(k)) is not int or usage[k] < 0 + for k in ('vram', 'ram')): + return None + kinds = [kind for key, kind in (('vram', 'gpu'), ('ram', 'ram')) if usage[key] >= THRESHOLD] + if not kinds: + return None + return dict(vram_gib=usage['vram'] / GiB, ram_gib=usage['ram'] / GiB, + kinds=kinds, signature='%d:%d' % (usage['vram'] // GiB, usage['ram'] // GiB)) + + +def _worse(current, idle): + request, code, _ = current + baseline, base_code, _ = idle + if baseline is None: + return False # Closing other programs cannot admit this request either. + if request is None: + return False # A refused request is reported by the failure card, not as slower. + return (code > base_code + or any(request[key] < baseline[key] for key in ( + 'pass1_resident_blocks', 'pass2_resident_blocks', 'decoder_resident_blocks')) + or request['decoder_offload'] != baseline['decoder_offload']) + + +def placements(machine, usage, *, kind='video', width=1344, height=768, frames=243, + steps=8, two_pass=True, task='t2va', refine=3, references=None, + step_cache=None, upscale=2, reference_pixels=0): + """Identical request and reserves, with and without the measured other usage.""" + idle = _machine(machine) + usage = net(usage, machine) + if idle is None or idle['mac'] or _mac(machine) or notice(usage) is None: + return None + current = dict(idle, vram_free=max(0, idle['vram_free'] - usage['vram']), + ram_available=max(0, idle['ram_available'] - usage['ram'])) + if kind == 'image': + from .image_gen.image_policy import plan_image + def plan(value): + return plan_image(gpu_budget_bytes=value['vram_free'], host_available_bytes=value['ram_available'], + width=width, height=height, reference_pixels=reference_pixels).to_dict() + base, occupied = plan(idle), plan(current) + worse = base['feasible'] and occupied['feasible'] and (any( + occupied[key] < base[key] for key in ('dit_resident_layers', 'te_resident_layers', + 'dit_kept_layers', 'vae_tile_size')) + or occupied['vae_tiling'] != base['vae_tiling'] + or occupied['host_mode'] != base['host_mode']) + else: + params = dict(width=width, height=height, frames=frames, steps=steps, two_pass=two_pass, + task=task, refine=refine, references=references, step_cache=step_cache, upscale=upscale) + base, occupied = _admit(idle, **params), _admit(current, **params) + worse = _worse(occupied, base) + return dict(idle=base, occupied=occupied, worse=bool(worse)) + + +def for_request(machine, usage, **request): + try: + comparison = placements(machine, usage, **request) + return notice(net(usage, machine)) if comparison and comparison['worse'] else None + except Exception: + return None + + +def at_failure(code, machine=None): + """Fresh reading at failure, independent of session deduplication/admission.""" + if code not in MEMORY_ERRORS: + return None + try: + if machine is None: + from .comfy_bridge import installation + _, machine = installation() + return notice(net(read(machine), machine)) + except Exception: + return None + + +FACTS = { + 'gpu': ('Other programs are using {vram_gib} GB of GPU memory.', '其他程序占用了 {vram_gib} GB 显存。'), + 'ram': ('Other programs are using {ram_gib} GB of RAM.', '其他程序占用了 {ram_gib} GB 内存。'), + 'both': ('Other programs are using {vram_gib} GB of GPU memory and {ram_gib} GB of RAM.', + '其他程序占用了 {vram_gib} GB 显存和 {ram_gib} GB 内存。'), +} + + +def join(text, suffix, zh=False): + """Append the fact as its own sentence: no space in Chinese, one in English.""" + text = (text or '').rstrip() + if not text: + return suffix + if text[-1] not in ('。!?' if zh else '.!?'): + text += '。' if zh else '.' + return text + suffix if zh else text + ' ' + suffix + + +def fact(value, zh=False): + if not isinstance(value, dict): + return '' + kinds = value.get('kinds') + key = 'both' if kinds == ['gpu', 'ram'] else 'gpu' if kinds == ['gpu'] else 'ram' if kinds == ['ram'] else None + try: + return FACTS[key][int(zh)].format(**{k: format(value[k], '.1f') for k in ('vram_gib', 'ram_gib')}) if key else '' + except (KeyError, TypeError, ValueError): + return '' diff --git a/web/cast_generate.css b/web/cast_generate.css index 2dac44de..65fc980e 100644 --- a/web/cast_generate.css +++ b/web/cast_generate.css @@ -13,6 +13,7 @@ .fv-cg-text:read-only{opacity:.7} .fv-cg-text::placeholder{color:var(--fv-muted,#7d8794)} .fv-cg-note{grid-column:1/-1;font-size:12px;line-height:1.6;color:var(--fv-muted,#aaaeb9)} +.fv-cg-side>.fv-other-usage{color:var(--fv-muted,#aaaeb9);line-height:1.6} /* A failure is the reason on one line, then the report button left and the retry right on the next. */ .fv-cg-note[data-failed=true],.fv-cg-note[data-failed=true] .fv-result-links,.fv-cg-note[data-failed=true] .fv-actions{display:contents} .fv-cg-note .fv-image-failure-reason{grid-column:1/-1} diff --git a/web/cast_generate.js b/web/cast_generate.js index 07238c7f..ecb0fd4b 100644 --- a/web/cast_generate.js +++ b/web/cast_generate.js @@ -5,6 +5,7 @@ import {createImageGenClient, imageGenProgress, imagePackUnavailableMessage, renderImageFailure, IMAGE_GEN_POLL_MS, IMAGE_GEN_INFO_POLL_MS} from './image_generation.js'; import {createImageLoras} from './image_lora.js'; +import {createOtherUsageNotice} from './other_usage.js'; export {imageGenerationSupported as castGenerateSupported} from './image_generation.js'; @@ -18,11 +19,11 @@ const VIEW_TITLES = Object.freeze({ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } const failureOf = error => ({error: error?.code || 'generation_failed', report_id: error?.report_id || null, - stage: error?.stage || '', fitted: error?.fitted || null, memory: error?.memory || ''}); + stage: error?.stage || '', fitted: error?.fitted || null, memory: error?.memory || '', other_usage: error?.other_usage || null}); const randomSeed = () => crypto.getRandomValues(new Uint32Array(1))[0] >>> 1; // The five pictures of one description. No DOM: the page below draws its state. -export function createCastGenerator({client, kind = 'character', onChange = () => {}, schedule = setTimeout, unschedule = clearTimeout}) { +export function createCastGenerator({client, kind = 'character', onChange = () => {}, beforeGenerate = async () => {}, schedule = setTimeout, unschedule = clearTimeout}) { const scene = kind === 'setting', names = scene ? SCENE_CANDIDATES : CAST_VIEWS; const fresh = () => names.map(view => ({view, file: '', fraction: 0})); let state = {pictures: fresh(), running: false, current: -1, error: '', report_id: null, waiting: '', description: ''}; @@ -34,6 +35,8 @@ export function createCastGenerator({client, kind = 'character', onChange = () = async function runOne(index, alive) { const view = plan[index], front = state.pictures[0].file; + await beforeGenerate({kind: 'image', width: view.width, height: view.height, image_references: view.from_front ? 1 : 0}); + if (!alive()) return null; const row = await client.generate({prompt: view.prompt, width: view.width, height: view.height, seed: scene ? seed + index : seed, images: view.from_front ? [front] : [], ...(view.view === 'back' ? {cast_view: 'back'} : {}), ...(loras === undefined ? {} : {loras})}); @@ -47,7 +50,7 @@ export function createCastGenerator({client, kind = 'character', onChange = () = if (!alive()) return null; if (status.state === 'error') { throw Object.assign(new Error(status.error), {code: status.error, report_id: status.report_id, - stage: status.stage, fitted: status.fitted, memory: status.memory}); + stage: status.stage, fitted: status.fitted, memory: status.memory, other_usage: status.other_usage}); } if (status.state === 'done') { if (!status.file) throw new Error('generation_failed'); @@ -168,7 +171,8 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt container.append(root); let chosen = 0, picked = '', info = null, infoTimer = null, disposed = false, failureKey = null, attention = null; - const generator = createCastGenerator({client, kind, onChange: render}); + const otherUsage = createOtherUsageNotice(side, tr, fetcher); + const generator = createCastGenerator({client, kind, onChange: render, beforeGenerate: query => otherUsage.check(query)}); let last = ''; function missing() { return info && !info.installed && !info.ready; } @@ -262,7 +266,7 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt : generator.state().pictures.map(row => ({file: row.file, label: row.view})), isBusy: () => generator.state().running, cancel: () => generator.stop(), - destroy() { disposed = true; picker?.dispose(); clearTimeout(infoTimer); void generator.dispose(); root.remove(); }, + destroy() { disposed = true; picker?.dispose(); clearTimeout(infoTimer); otherUsage.dispose(); void generator.dispose(); root.remove(); }, element: root, }; } diff --git a/web/error_panel.js b/web/error_panel.js index 7910e1fb..e05c0ba8 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -1,4 +1,5 @@ // Shared by the Studio and graph view. Text only; never render exception HTML. +import {readOtherUsage, otherUsageText} from './other_usage.js'; import {repairAction} from './repair_hint.js'; import {detectBrowserExtension, browserExtensionAdvice, extensionSteps, isNetworkTypeError, connectionLostText} from './browser_extension.js'; import {SAMPLING_EFFORTS, STEP_CACHES, UPSCALE_FACTORS, tableLevel} from './sampling_effort.js'; @@ -632,10 +633,19 @@ export function createErrorPanel(t, route = null) { } }; disclosure.append(summary, detailLocation, detailCause, details, copyInside, privacyInside); - head.append(title, actions); element.append(head, explanation, location, cause, action, disclosure, privacy, downloadStatus); + const occupancy = document.createElement('p'); occupancy.className = 'fv-muted fv-other-usage-failure'; occupancy.hidden = true; + head.append(title, actions); element.append(head, explanation, location, cause, action, occupancy, disclosure, privacy, downloadStatus); return {element, show(value, popup = true, context = {}) { const report = createErrorReport(value, context); currentReport = report; const {advice, generated} = adviceFor(report, t, value); + occupancy.hidden = true; occupancy.textContent = ''; + if (['vram', 'ram', 'commit', 'resources'].includes(advice.kind)) { + void readOtherUsage({failure: '1'}).then(notice => { + if (currentReport !== report) return; + occupancy.textContent = otherUsageText(notice, t, true); + occupancy.hidden = !occupancy.textContent; + }); + } title.textContent = advice.title; explanation.textContent = advice.summary; explanation.hidden = !advice.summary; action.textContent = advice.action; element.querySelector('.fv-extension-steps')?.remove(); if (advice.kind === 'browser-extension') action.after(extensionSteps(advice)); diff --git a/web/image_editor.css b/web/image_editor.css index 5207ca64..50b21fd4 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -115,6 +115,7 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .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-generation>.fv-other-usage{grid-column:1/-1;font-size:var(--fv-micro);color:var(--fv-muted);line-height:1.5} .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. */ diff --git a/web/image_generation.js b/web/image_generation.js index 23581160..e7a18ba8 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -6,6 +6,7 @@ import {saveReport, reportIssue, showReportTip, focusReportTip} from './report_i import {issueButton} from './issue_button.js'; import {about} from './request_assets.js'; import {createImageLoras} from './image_lora.js'; +import {createOtherUsageNotice, otherUsageText} from './other_usage.js'; export const IMAGE_GEN_PROTOCOL = Object.freeze({ root: '/freevideo/image-gen', header: 'X-FreeVideo-Image', upload: '/upload/image', @@ -51,6 +52,7 @@ export function blankCanvasSize(ratio, sizes = null, tier = 'standard') { const failure = (code, info = {}) => Object.assign(new Error(code || 'generation_failed'), { code: code || 'generation_failed', report_id: info.report_id || null, stage: info.stage || '', fitted: info.fitted || null, memory: info.memory || '', + other_usage: info.other_usage || null, }); export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThis)) { @@ -181,7 +183,8 @@ export function imageGenError(code, info, t) { small_memory: ['This Mac has less than 16 GB of memory and cannot generate pictures.', '这台 Mac 的内存不足 16 GB,无法生成图片。'], }; - const say = (en, zh) => t(...memoryWords([en, zh], info?.unified)); + const fact = ['no_memory', 'small_memory'].includes(code) ? otherUsageText(info?.other_usage, t, true) : ''; + const say = (en, zh) => t(...memoryWords([en, zh], info?.unified)) + (fact ? t(' ', '') + fact : ''); if (code === 'pack_missing') return imagePackUnavailableMessage(info, t); if (code === 'no_memory' && info?.memory === 'host') return t('Not enough virtual memory to load the image model. Close other apps, or increase virtual memory in system settings, then try again.', @@ -221,7 +224,7 @@ export const REPORT_LABELS = Object.freeze({ // 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, stage, fitted, info, memory} = {}, t, +export function renderImageFailure(host, {error, report_id, stage, fitted, info, memory, other_usage} = {}, t, {productVersion = () => null, fetchApi = globalThis.fetch.bind(globalThis)} = {}) { host.replaceChildren(); if (!error || error === 'cancelled') return null; @@ -232,7 +235,7 @@ export function renderImageFailure(host, {error, report_id, stage, fitted, info, } 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, {stage, fitted, memory, unified: info?.unified_memory}, t); + reason.setAttribute('role', 'status'); reason.textContent = imageGenError(error, {stage, fitted, memory, unified: info?.unified_memory, other_usage}, t); card.append(reason); host.append(card); if (!report_id) return card; const fetchReport = async () => { @@ -411,6 +414,7 @@ export function imageGenProgress(stage, fraction, withImages = false) { // An epoch rejects late uploads/status/results. A generate response arriving // after Stop still has to be cancelled, even though its job was not known yet. export function createImageGenSession({client, prepare, apply, onChange = () => {}, + beforeGenerate = async () => {}, schedule = setTimeout, unschedule = clearTimeout, store = null, visible = () => globalThis.document?.visibilityState !== 'hidden'}) { let epoch = 0, timer = null, infoTimer = null, disposed = false, checking = null; @@ -419,7 +423,7 @@ export function createImageGenSession({client, prepare, apply, onChange = () => const emit = patch => { state = {...state, ...patch}; if (!disposed) onChange(state); }; const failed = error => error.code === 'cancelled' ? {error: '', report_id: null, stage: '', memory: ''} : {error: error.code || 'generation_failed', report_id: error.report_id || null, stage: error.stage || '', - memory: error.memory || '', ...(error.fitted ? {fitted: error.fitted} : {})}; + memory: error.memory || '', other_usage: error.other_usage || null, ...(error.fitted ? {fitted: error.fitted} : {})}; const cancelJob = job => Promise.resolve().then(() => client.cancel(job)).catch(() => {}); const forget = () => { try { store?.clear(); } catch { /* the job is only not resumed after a reload */ } }; function scheduleInfo() { @@ -483,6 +487,8 @@ export function createImageGenSession({client, prepare, apply, onChange = () => if (!alive()) return; if (state.stage === 'upload') emit({stage: ''}); const withImages = images.length > 0; + await beforeGenerate({kind: 'image', width: context.width, height: context.height, image_references: images.length}); + if (!alive()) return; const row = await client.generate({prompt: request.prompt, width: context.width, height: context.height, images, mask, ...(request.seed === undefined ? {} : {seed: request.seed}), ...(request.loras === undefined ? {} : {loras: request.loras})}); @@ -571,6 +577,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection let store = null; try { if (resumeKey != null && globalThis.sessionStorage) store = imageJobStore(resumeKey); } catch { /* no storage */ } const session = createImageGenSession({client, store, + beforeGenerate: query => mounted?.otherUsage.check(query), prepare: async (request, alive) => { const context = generationContext(getDoc(), getSelection(), request.mode); const images = request.mode === 'edit' ? [await uploadGenerationReference(renderer, client, context, alive), @@ -872,6 +879,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection bar.append(group); if (shape) bar.append(shape); bar.append(field, note, action, meter); + const otherUsage = createOtherUsageNotice(bar, t, fetchApi); const grow = () => { input.style.height = '0px'; input.style.height = (prompt.trim() ? Math.min(80, Math.max(32, input.scrollHeight + 2)) : 32) + 'px'; @@ -887,13 +895,13 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection event.preventDefault(); void start(); } }; - mounted = {bar, input, action, note, meter, fill, group, refs, ratio, size, grow}; + mounted = {bar, input, action, note, meter, fill, group, refs, ratio, size, otherUsage, grow}; grow(); update(); } - return {mount, drawsSelection: () => mode === 'edit' && getDoc().layers.length > 0, unmount() { clearAttention(); promptRequired = false; mounted = null; }, + return {mount, drawsSelection: () => mode === 'edit' && getDoc().layers.length > 0, unmount() { clearAttention(); promptRequired = false; mounted?.otherUsage.dispose(); mounted = null; }, selected: () => session.check(), cancel: session.cancel, // The blank canvas a reloaded generation was started on, to be set again by the editor. resumedCanvas: resumed?.context?.mode === 'new' ? {width: resumed.context.doc.width, height: resumed.context.doc.height} : null, - dispose() { clearAttention(); loras?.dispose(); return session.dispose(); }, state: session.state}; + dispose() { clearAttention(); loras?.dispose(); mounted?.otherUsage.dispose(); return session.dispose(); }, state: session.state}; } diff --git a/web/other_usage.js b/web/other_usage.js new file mode 100644 index 00000000..ffa94042 --- /dev/null +++ b/web/other_usage.js @@ -0,0 +1,66 @@ +// All occupancy sentences shown by the page live here; numbers join GB with a no-break space. +export const OTHER_USAGE_COPY = Object.freeze({ + gpu: ['Other programs are using {vram_gib}\u00a0GB of GPU memory, so this generation will be slower. Closing them speeds up generation.', + '其他程序占用了 {vram_gib}\u00a0GB 显存,这次生成会变慢。关闭这些程序可加快生成。'], + ram: ['Other programs are using {ram_gib}\u00a0GB of RAM, so this generation will be slower. Closing them speeds up generation.', + '其他程序占用了 {ram_gib}\u00a0GB 内存,这次生成会变慢。关闭这些程序可加快生成。'], + both: ['Other programs are using {vram_gib}\u00a0GB of GPU memory and {ram_gib}\u00a0GB of RAM, so this generation will be slower. Closing them speeds up generation.', + '其他程序占用了 {vram_gib}\u00a0GB 显存和 {ram_gib}\u00a0GB 内存,这次生成会变慢。关闭这些程序可加快生成。'], + gpuFact: ['Other programs are using {vram_gib} GB of GPU memory.', '其他程序占用了 {vram_gib} GB 显存。'], + ramFact: ['Other programs are using {ram_gib} GB of RAM.', '其他程序占用了 {ram_gib} GB 内存。'], + bothFact: ['Other programs are using {vram_gib} GB of GPU memory and {ram_gib} GB of RAM.', + '其他程序占用了 {vram_gib} GB 显存和 {ram_gib} GB 内存。'], +}); +export const OTHER_USAGE_STORAGE = 'freevideo.otherUsage.lastNotice'; +export function otherUsageText(notice, t, fact = false) { + if (!notice || !['vram_gib', 'ram_gib'].every(k => Number.isFinite(notice[k]) && notice[k] >= 0)) return ''; + const gpu = notice.kinds?.includes('gpu'), ram = notice.kinds?.includes('ram'); + if (!gpu && !ram) return ''; + const key = (gpu && ram ? 'both' : gpu ? 'gpu' : 'ram') + (fact ? 'Fact' : ''); + return t(...OTHER_USAGE_COPY[key]).replaceAll('{vram_gib}', notice.vram_gib.toFixed(1)) + .replaceAll('{ram_gib}', notice.ram_gib.toFixed(1)); +} +export function claimOtherUsage(notice, storage) { + try { + storage ??= globalThis.sessionStorage; + const previous = JSON.parse(storage.getItem(OTHER_USAGE_STORAGE) || 'null'); + if (!notice?.signature || previous?.signature === notice.signature) return false; + // A floor-bucket boundary alone can be less than 1 GiB of actual change. + if (previous && Math.abs(previous.vram_gib - notice.vram_gib) < 1 + && Math.abs(previous.ram_gib - notice.ram_gib) < 1) return false; + storage.setItem(OTHER_USAGE_STORAGE, JSON.stringify(notice)); + return true; + } catch { return false; } // Cannot persist the once-only rule: stay quiet. +} +export async function readOtherUsage(query, fetchApi = globalThis.fetch.bind(globalThis)) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 750); + try { + // Race also bounds adapters that ignore AbortSignal. No advisory can hold submission. + return await Promise.race([ + (async () => { + const response = await fetchApi('/freevideo/other-usage?' + new URLSearchParams(query), + {cache: 'no-store', signal: controller.signal}); + const value = response.ok ? await response.json() : null; + return value?.status === 'ok' ? value.notice : null; + })(), + new Promise(resolve => controller.signal.addEventListener('abort', () => resolve(null), {once: true})), + ]); + } catch { return null; } + finally { clearTimeout(timeout); } +} +export function createOtherUsageNotice(host, t, fetchApi) { + const element = (host.ownerDocument || document).createElement('div'); + element.className = 'fv-muted fv-other-usage'; element.hidden = true; element.setAttribute('role', 'status'); + host.append(element); + let revision = 0; + return {element, async check(query) { + const current = ++revision; + element.hidden = true; element.textContent = ''; + if (!query) return; + const notice = await readOtherUsage(query, fetchApi); + if (current !== revision || !element.isConnected) return; + const text = otherUsageText(notice, t); + if (text && claimOtherUsage(notice)) { element.textContent = text; element.hidden = false; } + }, dispose() { revision++; element.remove?.(); }}; +} diff --git a/web/studio.css b/web/studio.css index ed8cf5ff..648fd686 100644 --- a/web/studio.css +++ b/web/studio.css @@ -117,6 +117,7 @@ .fv-studio .fv-stats[data-count="3"]{grid-template-columns:repeat(3,minmax(0,1fr))} .fv-studio .fv-result-links{animation:fv-panel-enter .35s ease;display:flex;align-items:center;justify-content:center;gap:9px;flex-wrap:wrap} .fv-studio .fv-runbar{position:sticky;bottom:0;padding:16px 0 22px;background:linear-gradient(transparent,var(--fv-bg) 12%);z-index:2} +.fv-studio .fv-runbar>.fv-other-usage{margin-bottom:8px} .fv-studio .fv-runbar .fv-primary{width:100%;min-height:var(--fv-h-lg);font-size:14px} .fv-studio .fv-runrow{display:flex;gap:6px;align-items:stretch} .fv-studio .fv-runrow>.fv-primary{flex:1;min-width:0} diff --git a/web/studio.js b/web/studio.js index 0e0c1363..fbf9283a 100644 --- a/web/studio.js +++ b/web/studio.js @@ -38,7 +38,8 @@ import { requestText, shownRewrite } from './prompt_sections.js'; import { createRequestAssets, enabledLoras, referenceFiles } from './request_assets.js'; import { promptDraftTrouble, rememberPromptDraft, restorePromptDraft, savePromptDraft, trackPromptDraft, watchPromptDraftTrouble } from './prompt_draft.js'; import { canvasDimensions, imageSizeSelection } from './studio_size.js'; -import { createCapabilityView } from './capability_view.js'; +import { createCapabilityView, capabilityContext } from './capability_view.js'; +import { createOtherUsageNotice } from './other_usage.js'; import { openEditorView } from './editor_loader.js'; import { frameShapes, frameSize } from './frame_sizes.js'; import { createFirstRunGuide, createFirstStart } from './first_run_guide.js'; @@ -1678,6 +1679,8 @@ export function openStudio(node, {extendFrom = null} = {}) { resumeChain.hidden = !['cancelled', 'failed', 'interrupted'].includes(chainController?.state?.state) || partList.resumes(); queueActions.hidden = !chainController?.active && !busy && !queueState?.looping && resumeChain.hidden; }; + const otherUsage = createOtherUsageNotice(runbar, t, api.fetchApi.bind(api)); + cleanup.push(() => otherUsage.dispose()); runbar.append(requestAssets.element, runrow, queueActions); controls.append(runbar); const queueError = error => ({ queue_unavailable: t('Could not read the queue. The loop has stopped; videos already added stay queued.', '暂时读不到队列,循环已停止;已加入的视频还在队列中。'), @@ -1931,6 +1934,17 @@ export function openStudio(node, {extendFrom = null} = {}) { if (lengthControl.absent && !seconds.reportValidity()) return; if (lengthPlan?.count > 1 && chosenMode !== 'single') { status.textContent = t('Videos made in parts cannot use batch or loop.', '分段视频不能批量或循环生成。'); return; } const lengthOptions = lengthControl.options(); + const usageContext = capabilityContext(node, {custom: true}); + const usageQuery = usageContext && !extension.source && !seconds.disabled ? new URLSearchParams(usageContext) : null; + // Missing material dimensions or linked settings cannot describe the same request reliably. + if (usageQuery && (!usageQuery.get('task').startsWith('ref2va') || usageQuery.has('references'))) { + for (const [key, val] of Object.entries({width: value(node, 'width'), height: value(node, 'height'), + seconds: lengthPlan?.partSeconds?.[0] ?? Number(seconds.value), base_steps: baseSteps.value, + refine_steps: refineSteps.value, two_pass: twoPass.checked ? '1' : '0', + step_cache: stepCacheNow(), upscale_factor: upscaleNow()})) usageQuery.set(key, val); + await otherUsage.check(usageQuery); + } else await otherUsage.check(null); + if (disposed) return; const source = extension.source; const sourceState = source ? await extendSourceState(api, source) : 'ok'; if (sourceState !== 'ok') throw new Error(extendSourceProblem(t, sourceState)); From b2104e6746b1c59417a675ad9316363931cf9298 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 10/26] Tests: the image model card says verifying for a finished download and checking for a requested... Tests: the image model card says verifying for a finished download and checking for a requested check --- freevideo_engine/image_gen/image_pack.py | 6 +++++- freevideo_engine/launcher_session.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/image_gen/image_pack.py b/freevideo_engine/image_gen/image_pack.py index 16ac5926..4fdd0c33 100644 --- a/freevideo_engine/image_gen/image_pack.py +++ b/freevideo_engine/image_gen/image_pack.py @@ -472,6 +472,7 @@ def verify(root=None, progress=None, check=None, *, group='image'): def _verify(folder, root, progress, check, full=False, *, group='image'): ledger = _read(folder, LEDGER) + snapshot = json.dumps(ledger, sort_keys=True) todo = [] total = sum(r['bytes'] for r in rows(group=group)) done = 0 @@ -495,7 +496,8 @@ def checked(current, size): model_repair.record(folder, path, row, before) todo.append(row) done += row['bytes'] - save(_path(folder, LEDGER), ledger) + if json.dumps(ledger, sort_keys=True) != snapshot or not _path(folder, LEDGER).is_file(): + save(_path(folder, LEDGER), ledger) return ledger, todo @@ -537,6 +539,8 @@ def event(value): details.update(verification='mismatch', method='sha256') if diagnostic: diagnostic(dict(phase='diagnostic', diagnostic=details)) + if value.get('action') == 'verifying': + diagnostic(dict(phase='verify')) # A finished file is being hashed: say so, not "downloading". if prior_event: prior_event(value) plan['event_callback'] = event diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index d2aa157a..7695d907 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -812,6 +812,8 @@ def image_pack_view(self): labels = {'install': ('Downloading', '正在下载'), 'repair': ('Repairing', '正在修复'), 'remove': ('Removing', '正在删除'), 'verify': ('Checking', '正在检查')} detail = self.t(*labels[active['action']]) + if active.get('action') == 'install' and active.get('phase') == 'verify': + detail = self.t('Checking', '正在校验') elif active.get('state') == 'paused_for_generation': detail = self.t('Paused while generating', '生成时暂停') elif active.get('state') == 'cancelled': From cec987dd6a678c81c36fa46a4b41aa5bdb4697aa Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 11/26] Name the language and item when release notes are rejected --- freevideo_engine/release_notes.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/release_notes.py b/freevideo_engine/release_notes.py index 122e5325..de97dcf1 100644 --- a/freevideo_engine/release_notes.py +++ b/freevideo_engine/release_notes.py @@ -22,6 +22,29 @@ def _text(value, limit): and not any(ord(c) < 32 for c in value)) +def problems(value): + """Why a notes block is rejected, naming the language and item; [] when it is usable.""" + if not isinstance(value, dict) or value.get('schema') != 1: + return ['schema must be 1'] + found = [] + for language in ('en', 'zh'): + row = value.get(language) + if not isinstance(row, dict): + found.append('%s: missing' % language) + continue + if not _text(row.get('summary'), 400): + found.append('%s: summary is empty or over 400 characters' % language) + changes = row.get('changes') + if not isinstance(changes, list) or not 1 <= len(changes) <= 12: + found.append('%s: needs 1-12 changes, has %s' % (language, len(changes) if isinstance(changes, list) else 'none')) + continue + found += ['%s: change %d is empty, over 600 characters or has a control character' % (language, number) + for number, item in enumerate(changes, 1) if not _text(item, 600)] + if not found and not notes(value): + found.append('total is over %d bytes' % MAX_NOTES_BYTES) + return found + + def notes(value): """Ignore malformed optional metadata so legacy/update checks still work.""" if not isinstance(value, dict) or value.get('schema') != 1: @@ -112,7 +135,9 @@ def catalog(package=None): value = json.loads((package / 'release_notes.json').read_text(encoding='utf-8')) version, release = product_version(value.get('product_version')), notes(value) if not version or not release: - raise ValueError('Release notes need a product version and complete English/Chinese text') + raise ValueError('Release notes need a product version and complete English/Chinese text' + + ('' if version else ' (product_version is missing or invalid)') + + ''.join('; ' + why for why in problems(value))) earlier = history(value.get('history'), before=version) if len(earlier) != len(value.get('history') or []): raise ValueError('Every earlier version in release_notes.json needs complete English/Chinese text') From 455cf8ec6eeedc09952ef1b5245f96de86c46f85 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:20 +0000 Subject: [PATCH 12/26] Editor playback failure sentence in formal Chinese --- web/editor.js | 4 ++++ web/editor_player.js | 23 +++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/web/editor.js b/web/editor.js index 61df5a51..62fa2b6d 100644 --- a/web/editor.js +++ b/web/editor.js @@ -324,6 +324,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given return {ok: false, reason: code}; } if (['audio_prepare', 'speed_pending', 'speed_locked', 'speed'].includes(code)) return {ok: false, reason: code}; + if (value?.code === 'playback') { + const text = t('This clip cannot be played.', '这段素材无法播放。'); + out(text, EDITOR_ISSUE, failureKind('playback', value)); return {ok: false, reason: code, say: text}; + } if (code === 'unsupported') { genbar?.update(null); return; } if (code === 'text_long') { const text = textLongText(t, Number.isInteger(value.limit) ? value.limit : 8000); diff --git a/web/editor_player.js b/web/editor_player.js index 3a7cb783..000a8409 100644 --- a/web/editor_player.js +++ b/web/editor_player.js @@ -73,9 +73,12 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err 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 = () => { + // A refused start or an unplayable file never succeeds by asking again, nor does a run of refusals. + const rejected = reason => { if (!current()) return; media._editorPlay = null; media._editorPlayFailed = true; + if (reason?.name === 'NotAllowedError') { media._editorPlayStuck = 'blocked'; return; } + if (reason?.name === 'NotSupportedError' || (media._editorPlayTries = (media._editorPlayTries || 0) + 1) > 25) { media._editorPlayStuck = 'failed'; return; } setTimeout(() => { if (disposed || media.dataset.source !== attempt.url) return; media._editorPlayFailed = false; startMedia(media); @@ -84,16 +87,17 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err try { Promise.resolve(media.play()).then(() => { if (!current()) { if (disposed || !media._editorRunning) media.pause(); return; } - media._editorPlay = null; + media._editorPlay = null; media._editorPlayTries = 0; }, rejected); - } catch { rejected(); } + } catch (reason) { rejected(reason); } } + const freshStart = media => { media._editorPlayFailed = false; media._editorPlayTries = 0; media._editorPlayStuck = null; }; 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) { stopMedia(media); media.dataset.source = url; media.src = url; media._editorPlayFailed = false; media.load(); } - if (running && !media._editorRunning) media._editorPlayFailed = false; + if (changed) { stopMedia(media); media.dataset.source = url; media.src = url; freshStart(media); media.load(); } + if (running && !media._editorRunning) freshStart(media); media._editorTime = Math.max(0, seconds); media._editorRunning = running; volume(media, gain); const drift = Math.abs(media.currentTime - media._editorTime); @@ -381,11 +385,18 @@ export function createEditorPlayer(host, transport, {state, sources, api, t, err 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. + // Missing media continues through the ordinary fallback; failed media has already stopped playback. return original.some(usable) ? state.playhead : null; } function tick(now) { if (!playing || disposed) return; + // Picture or sound this spot needs cannot play: stop here rather than let time run over black or silence. + // A refused start stops quietly; pressing Play again is the gesture the browser asks for. + const broken = [...videos, ...audio].find(media => media._editorRunning && (media.error || media._editorPlayStuck)); + if (broken) { + const reason = broken.error ? 'media_' + broken.error.code : broken._editorPlayStuck; + pause(); if (reason !== 'blocked') error({code: 'playback', reason}); 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); From 2e5662e80207787633b7af6a9d77eecbab6b5956 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 13/26] Export interruption after a restart in formal wording --- web/editor.js | 22 ++++++++++++++++++---- web/editor_store.js | 4 ++-- web/editor_text.js | 2 +- web/studio_chain.js | 8 +++++++- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/web/editor.js b/web/editor.js index 62fa2b6d..995545fc 100644 --- a/web/editor.js +++ b/web/editor.js @@ -632,7 +632,19 @@ export function openEditor({api, app, from = null, panel = {}, components: given } function watchJob(id) { if (disposed) return; - if (!jobWatchers.has(id)) jobWatchers.set(id, store.watchChain(id, deliverChain)); + if (!jobWatchers.has(id)) jobWatchers.set(id, store.watchChain(id, job => job ? deliverChain(job) : chainGone(id))); + } + // The engine no longer has this job: its unfinished parts stop where they are, as if the engine had dropped them. + function chainGone(id) { + if (disposed) return false; + const previous = jobStatuses.get(id); + if (previous) return deliverChain({...previous, state: 'interrupted', + blocks: (previous.blocks || []).map(block => ['pending', 'queued', 'running'].includes(block.state) ? {...block, state: 'interrupted'} : block)}); + jobWatchers.delete(id); + const ids = state.record.tracks[0].clips.filter(clip => clip.job === id && ['queued', 'running'].includes(clip.pending)).map(clip => clip.id); + if (ids.length) state.settle(record => pinStoppedJob(record, ids, id)); + updateTop(); updateTransport(); + return false; } function recoverJobs() { for (const id of new Set(state.record.tracks[0].clips.map(clip => clip.job).filter(Boolean))) watchJob(id); @@ -1166,7 +1178,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given } } }, - watch: (id, fn) => store.watchEars(id, fn), + watch: (id, fn, lost) => store.watchEars(id, fn, lost), cancel: id => store.cancelEars(id), // Whether speech can be recognised here now, and how many bytes must be downloaded first. async ability() { @@ -1179,7 +1191,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (install?.state !== 'running') { resolve(install?.state === 'done' || install?.reason || false); return false; } report(install.total > 0 ? install.done / install.total : 0); return true; }; - store.installEars().then(value => { if (step(value)) store.watchEarsPack(step); }, reject); + store.installEars().then(value => { if (step(value)) store.watchEarsPack(step, reject); }, reject); }), cancelDownload: () => store.cancelEarsPack(), }, @@ -1227,7 +1239,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given notify(t('Lost the connection to FreeVideo, so the export progress cannot be read. Export again once it is back.', '与 FreeVideo 的连接已断开,无法读取导出进度。恢复后请重新导出。'), EXPORT_ISSUE, 'export:lost'); } function updateExport(value) { - if (!value) return false; + // A running export the engine no longer knows was lost in a restart: free the button and say so. + if (!value) { if (!disposed && exportStatus?.state === 'running') { exportStatus = null; exportCancel.hidden = true; updateTop(); + notify(t('FreeVideo restarted and the export stopped. Export again.', 'FreeVideo 已重启,导出已中断。请重新导出。'), EXPORT_ISSUE, 'export:gone'); } return false; } 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; } diff --git a/web/editor_store.js b/web/editor_store.js index 87622971..a3d79eaa 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -535,13 +535,13 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener // Subtitles from speech. What this computer can recognise; null where it cannot or the server is older. async ears() { return (await json('/freevideo/editor/ears'))?.available || null; }, async startEars(id, revision) { return (await json('/freevideo/editor/ears', post({kind: 'transcribe', id, revision})))?.job || null; }, - watchEars(id, fn) { return watch(async () => (await json('/freevideo/editor/ears?' + new URLSearchParams({id})))?.job || null, fn); }, + watchEars(id, fn, lost) { return watch(async () => (await json('/freevideo/editor/ears?' + new URLSearchParams({id})))?.job || null, fn, lost); }, cancelEars: id => json('/freevideo/editor/ears/cancel', post({id})), // What recognising speech needs is downloaded on first use; null where the server has no such download. earsPack: () => json('/freevideo/editor/subtitle-pack'), installEars: () => json('/freevideo/editor/subtitle-pack', post({action: 'install', language: true})), cancelEarsPack: () => json('/freevideo/editor/subtitle-pack', post({action: 'cancel'})), - watchEarsPack(fn) { return watch(() => json('/freevideo/editor/subtitle-pack'), fn); }, + watchEarsPack(fn, lost) { return watch(() => json('/freevideo/editor/subtitle-pack'), fn, lost); }, // Music for a stretch of the sound track. {available, ready}; null where the server is older. musicInfo: () => json('/freevideo/editor/music'), // body: {frames, text?} or {frames, style, mood}, optional seed. Refusals keep the server's reason. diff --git a/web/editor_text.js b/web/editor_text.js index df42b3fb..fb2fa5dc 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -105,7 +105,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, if(run!==speech.run){if(job?.id)Promise.resolve(actions.speech.cancel(job.id)).catch(()=>{});return;} if(!job){speechEnd(speechFailed(),SPEECH_ISSUE);return;} speech.job=job.id??null; - if(speechResult(job))speech.stop=actions.speech.watch(job.id,value=>{if(run!==speech.run)return false;if(!value){speechEnd(speechFailed(),SPEECH_ISSUE);return false;}return speechResult(value);}); + if(speechResult(job))speech.stop=actions.speech.watch(job.id,value=>{if(run!==speech.run)return false;if(!value){speechEnd(speechFailed(),SPEECH_ISSUE);return false;}return speechResult(value);},()=>{if(run===speech.run)speechEnd(speechFailed(),SPEECH_ISSUE);}); }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download. Try again.','没能下载,请再试一次。'):speechFailed(),SPEECH_ISSUE);} } function commitDraft(){if(draft){const change=draft.change;apply(t('Text','文字'),change);const c=selectedText();if(c?.text_kind==='text')lastStyle={...styleOf(state.record,c)};}} diff --git a/web/studio_chain.js b/web/studio_chain.js index 0f812787..61bc812f 100644 --- a/web/studio_chain.js +++ b/web/studio_chain.js @@ -176,7 +176,13 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( const id = chain.id, version = epoch; poller = createPoller(async () => (await json(chainRoute(id)))?.chain || null, value => version === epoch && !disposed ? deliver(value, fresh) : false, - {interval: pollMs, clock}); + // The engine no longer has this chain: stop asking and show it stopped, so Continue is offered. + {interval: pollMs, clock, onError: error => { + if (error?.status !== 404 || version !== epoch || disposed || chain?.id !== id) return; + poller?.dispose(); + deliver({...chain, state: 'interrupted', joining: false, output_state: chain.output_state === 'joining' ? null : chain.output_state, + blocks: (chain.blocks || []).map(block => ['pending', 'queued', 'running'].includes(block.state) ? {...block, state: 'interrupted'} : block)}); + }}); } function adopt(value, metadata = null) { if (chain?.id !== value.id) { epoch++; previous = {}; lastMessage = {}; delivered = errorShown = null; corrected = false; etaAt = 0; lastIndex = -1; } From 0afd06f5260c55dba3ae61cc93e18702faaf96e6 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 14/26] Phone access reasons in formal wording --- freevideo_engine/comfy_launcher_runtime.py | 23 ++++++++++- freevideo_engine/lan_page.py | 45 +++++++++++++++++++++- web/lan_access.css | 1 + web/lan_access.js | 38 +++++++++++++----- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index 7feb804f..367c3120 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -424,6 +424,13 @@ def bind_check(hostname, port): raise +def lan_error(reason, message): + """The launcher's message, with the lan_page reason code the phone panel shows.""" + error = ValueError(message) + error.lan_reason = reason + return error + + def address(hostname, port): return 'http://%s:%d' % ('[%s]' % hostname if ':' in hostname else hostname, port) @@ -1263,6 +1270,18 @@ def _connect(self): self._configure_access(dict(lan_access=(self.selection or {}).get('lan_access')), connect=True) def _start_access_proxy(self): + from . import lan_page + engine = self.selection['engine'] + try: + self._open_access_proxy() + except Exception as error: + # The page's phone panel shows why instead of waiting; a launcher closing is not a failure. + if not (self._closed or self.cancelled.is_set()): + lan_page.note_failure(engine, getattr(error, 'lan_reason', 'failed')) + raise + lan_page.forget_failure(engine) + + def _open_access_proxy(self): from .comfy_service import TOKEN, check_proxy, folder, start_proxy, token self.selection = selected = dict(self.selection, lan_access=False) self.state = dict(self.state, selection=dict(selected)) @@ -1270,7 +1289,7 @@ def _start_access_proxy(self): info = server_info(selected['url']) hosts = (info.get('listen') or '').split(',') if not hosts or not all(loopback_host(host) for host in hosts): - raise ValueError('The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.') + raise lan_error('comfy_on_network', 'The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.') configuration = tuple(selected[k] for k in ('root', 'engine', 'source', 'python', 'url')) if (self.access_configuration != configuration or self.access_proxy is not None and self.access_proxy.poll() is not None): @@ -1281,7 +1300,7 @@ def _start_access_proxy(self): port = (urlsplit(selected['url']).port or 80) + 1 candidate = next_free_address(address('0.0.0.0', port - 1)) if port <= 65535 else None if candidate is None: - raise ValueError('The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.') + raise lan_error('ports_in_use', 'The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.') port = urlsplit(candidate).port self.selection = selected = dict(selected, lan_port=port) self.state = dict(self.state, selection=dict(selected)) diff --git a/freevideo_engine/lan_page.py b/freevideo_engine/lan_page.py index ecdae418..34e763b0 100644 --- a/freevideo_engine/lan_page.py +++ b/freevideo_engine/lan_page.py @@ -1,10 +1,46 @@ """Local page LAN settings, independent of the HTTP server.""" +import json +from pathlib import Path import socket import time from . import lan_auth, lan_password from .launcher_settings import Store, default_language +# Why the launcher could not open phone access, for the page's phone panel. +# The launcher writes it when opening fails; turning the switch from the page clears it. +FAILURE_FILE = 'lan-failure.json' +REASONS = ('ports_in_use', 'comfy_on_network', 'failed') +# An older note belongs to an earlier session; the panel then just shows the switch off. +FAILURE_SECONDS = 600 + + +def note_failure(engine, reason): + try: + path = Path(engine) / 'launcher' / FAILURE_FILE + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(dict(reason=reason if reason in REASONS else 'failed')), encoding='utf-8') + except OSError: + pass + + +def forget_failure(engine): + try: + (Path(engine) / 'launcher' / FAILURE_FILE).unlink() + except OSError: + pass + + +def failure(engine): + path = Path(engine) / 'launcher' / FAILURE_FILE + try: + if not 0 <= time.time() - path.stat().st_mtime <= FAILURE_SECONDS: + return '' + reason = json.loads(path.read_text(encoding='utf-8')).get('reason') + except (OSError, ValueError, AttributeError): + return '' + return reason if reason in REASONS else '' + class LanUnavailable(RuntimeError): """The launcher has not recorded an installation on this computer.""" @@ -35,9 +71,12 @@ def read(engine): from .comfy_launcher_runtime import lan_access_links links = lan_access_links(engine, port, password_set=password_set) link = links[0] if links else '' - return dict(available=installation is not None, enabled=enabled, starting=flag and not enabled, + # Listening without a network address, or the launcher's last attempt failed. + reason = ('no_address' if enabled and not link else '' if enabled or installation is None + else failure(engine)) + return dict(available=installation is not None, enabled=enabled, starting=flag and not enabled and not reason, link=link, password_set=password_set, devices=len(auth.devices(time.time())), - min_length=lan_password.MIN_LENGTH) + min_length=lan_password.MIN_LENGTH, reason=reason) def apply(engine, body): @@ -68,6 +107,8 @@ def apply(engine, body): else: lan_password.set_password(engine, body['password']) if 'enabled' in body: + # A new attempt (or turning it off) replaces the last failure. + forget_failure(engine) installation = dict(installation, lan_access=body['enabled']) # access.lan_enabled() checks the current top-level permission copies. saved['lan_access'] = body['enabled'] diff --git a/web/lan_access.css b/web/lan_access.css index 3732cce4..f1ab1581 100644 --- a/web/lan_access.css +++ b/web/lan_access.css @@ -6,3 +6,4 @@ .fv-lan-tip{position:fixed;z-index:1001;background:var(--fv-raised);color:var(--fv-ink);font-size:var(--fv-micro);padding:6px 10px;border:1px solid var(--fv-border);border-radius:6px;white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px #0004} /* The extra tool must not take the readable project name out of a narrow editor. */ @media(max-width:680px){.fv-studio.fv-editor .fv-header[data-fit~=wrap][data-fit~=compact] .fv-wordmark{display:none}} +.fv-lan-problem{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin:12px 0 0}.fv-lan-reason{font-size:var(--fv-body);color:var(--fv-muted);line-height:1.65;margin:0}.fv-lan.fv-lan .fv-lan-retry{flex:none;color:var(--fv-accent);background:none;height:32px;padding:0 4px} diff --git a/web/lan_access.js b/web/lan_access.js index a3b26075..aecb3490 100644 --- a/web/lan_access.js +++ b/web/lan_access.js @@ -40,8 +40,20 @@ export function createLanAccess(t, api) { const error = el('p', 'fv-lan-error', t('Please try again.', '请重试。')); error.hidden = true; error.setAttribute('role', 'status'); content.append(qr, el('p', 'fv-lan-scan', t('Connect to the same Wi-Fi, then scan', '手机连同一个 Wi-Fi 后扫码')), copy, passwordRow, form); const tip = el('span', 'fv-lan-tip', label); tip.setAttribute('role', 'tooltip'); tip.hidden = true; - panel.append(head, starting, content, error); element.append(trigger, panel, tip); - let state = {enabled: false, link: '', password_set: false}, seen = false, disposed = false, writing = false, desired = null, reading = false, timer = null, copied = null, drawn = ''; + // Why phone access is not open: the launcher's reason, or no link after openWait. + const problem = el('div', 'fv-lan-problem'), problemText = el('p', 'fv-lan-reason'), retry = button('fv-lan-retry', t('Try again', '重试'), () => open()); + problem.append(problemText, retry); problem.hidden = true; problemText.setAttribute('role', 'status'); + const reasons = { + ports_in_use: t('The ports for phone access are in use or reserved by the system.', '手机连接所需的端口已被占用或被系统保留。'), + comfy_on_network: t('This ComfyUI is already open to the network. Restart it on this computer only, then try again.', 'ComfyUI 已直接对网络开放。请改为仅在本机打开后重试。'), + no_address: t('No LAN address found. Connect to Wi-Fi or a wired network first.', '未找到局域网地址,请先连接 Wi-Fi 或有线网络。'), + failed: t('Phone access could not be opened.', '手机连接无法打开。'), + late: t('Phone access has not opened yet. Make sure the FreeVideo launcher is running.', '手机连接尚未打开。请确认 FreeVideo 启动器正在运行。'), + unavailable: t('Phone access needs FreeVideo started from its launcher on this computer.', '手机连接需要通过本机的 FreeVideo 启动器启动。'), + }; + const openWait = 30000; + panel.append(head, starting, problem, content, error); element.append(trigger, panel, tip); + let state = {enabled: false, link: '', password_set: false}, seen = false, disposed = false, writing = false, desired = null, reading = false, timer = null, copied = null, drawn = '', since = 0; try { seen = localStorage.getItem(seenKey) === '1'; } catch {} const windows = /Win/i.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || ''); function fail() { error.hidden = false; } @@ -51,9 +63,14 @@ export function createLanAccess(t, api) { panel.style.left = `${Math.max(8, Math.min(box.right - 264, window.innerWidth - 272))}px`; } function render() { - const on = desired ?? state.enabled, pending = on && (!state.enabled || !state.link); - toggleButton.setAttribute('aria-checked', String(!!on)); toggleButton.disabled = writing || desired != null; - content.hidden = !on || pending; starting.hidden = !pending; + // No launcher installation: the switch cannot work from this page, so say why instead of "Please try again". + const unavailable = state.available === false, reason = unavailable ? 'unavailable' : reasons[state.reason] ? state.reason : '', on = desired ?? (state.enabled || state.starting); + const linked = !!(state.enabled && state.link), waiting = on && !linked && !reason; + if (!waiting) since = 0; else if (!since) since = Date.now(); + const late = waiting && Date.now() - since >= openWait, pending = waiting && !late, shown = reason || (late ? 'late' : ''); + toggleButton.setAttribute('aria-checked', String(!!on)); toggleButton.disabled = writing || desired != null || unavailable; + content.hidden = !on || !linked; starting.hidden = !pending; + problem.hidden = !shown; problemText.textContent = shown ? reasons[shown] : ''; retry.disabled = writing; retry.hidden = unavailable; starting.replaceChildren(); if (pending && windows) { if (t('en', 'zh') === 'zh') { starting.append(document.createTextNode('Windows 会弹出确认框,'), el('br'), el('span', '', '请勾选“专用网络”,再点“允许访问”。')); } @@ -61,7 +78,7 @@ export function createLanAccess(t, api) { } else if (pending) starting.textContent = t('Opening…', '正在打开…'); passwordState.textContent = state.password_set ? t('Password: on', '密码:已设置') : t('Password: not set', '密码:未设置'); set.textContent = state.password_set ? t('Change', '修改') : t('Set', '设置'); - if (on && !pending && state.link !== drawn) { try { drawQr(qr, state.link); drawn = state.link; } catch { fail(); } } + if (!content.hidden && state.link !== drawn) { try { drawQr(qr, state.link); drawn = state.link; } catch { fail(); } } position(); } async function read() { @@ -69,7 +86,7 @@ export function createLanAccess(t, api) { try { const response = await agentGet(api, '/freevideo/lan', {signal: AbortSignal.timeout(10000)}); if (!response.ok) throw new Error(); const value = await response.json(); if (disposed || writing) return; - state = value; if (desired === state.enabled && (!desired || state.link)) desired = null; + state = value; if (state.reason || desired === state.enabled && (!desired || state.link)) desired = null; error.hidden = true; render(); } catch { if (!disposed) fail(); } finally { reading = false; if (!disposed && !panel.hidden) timer = setTimeout(read, document.hidden ? 10000 : 2000); } @@ -88,13 +105,16 @@ export function createLanAccess(t, api) { async function write(body) { writing = true; error.hidden = true; render(); try { - const response = await agentPost(api, '/freevideo/lan', body); if (!response.ok) throw new Error(); + const response = await agentPost(api, '/freevideo/lan', body); + if (response.status === 409 && (await response.json().catch(() => null))?.error?.code === 'lan_unavailable') { if (!disposed) state = {...state, available: false}; return false; } + if (!response.ok) throw new Error(); const value = await response.json(); if (disposed) return false; if (typeof value.enabled === 'boolean') state = {...state, ...value}; return true; } catch { if (!disposed) fail(); return false; } finally { writing = false; if (!disposed) render(); } } - async function changeEnabled() { desired = !state.enabled; if (!await write({enabled: desired})) desired = null; render(); read(); } + async function changeEnabled() { await open(!(state.enabled || state.starting)); } + async function open(enabled = true) { since = 0; desired = enabled; if (!await write({enabled})) desired = null; render(); read(); } form.onsubmit = async event => { event.preventDefault(); if (save.disabled || writing) return; save.disabled = true; if (await write({password: password.value})) { password.value = ''; form.hidden = true; set.focus(); read(); } else password.oninput(); }; function observe(value) { if (!seen && longGeneration(value)) dot.hidden = false; } const opened = () => { seen = true; dot.hidden = true; }; From 6e268b956cfd2f853e4acf9484f8fd0b0d66a14e Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 15/26] Setup reasons test: read the visible installation status line --- freevideo_engine/failure_details.py | 13 ++++++++ freevideo_engine/launcher/Main.qml | 4 ++- freevideo_engine/launcher_copy.py | 1 + freevideo_engine/launcher_session.py | 49 +++++++++++++++++++++++++--- freevideo_engine/modern_launcher.py | 6 ++-- web/generation_progress.js | 14 +++++--- web/image_generation.js | 14 +++++--- web/report_issue.js | 10 +++++- web/result_actions.js | 15 ++++++--- web/setup.js | 33 +++++++++++++++++-- 10 files changed, 134 insertions(+), 25 deletions(-) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 9f44db36..a62984ca 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -599,6 +599,19 @@ def _launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_re from .model_guidance import DOWNLOAD_COPY return dict(title=DOWNLOAD_COPY['error_read'][zh], detail='', action='', kind='download-list-read') rules = ( + (r'This port is in use by another application', 'port-taken', + ('The ComfyUI port is in use', 'ComfyUI 端口被占用'), + ('Another program is using this port. Change the ComfyUI address in Settings, then start again.', + '有其他程序占用了这个端口。请在设置里换一个 ComfyUI 地址,再重新启动。')), + # Windows reserves whole port ranges (Hyper-V, WSL), so the ports after it may be unusable too. + (r'This port and the next ones are in use or reserved by the system', 'port-taken', + ('The ComfyUI port cannot be used', 'ComfyUI 端口无法使用'), + ('This port and the next ones are in use or reserved by the system. Change the ComfyUI address in Settings, ' + 'then start again.', '这个端口和后面的端口都已被占用或被系统保留。请在设置里换一个 ComfyUI 地址,再重新启动。')), + (r'The ports for LAN access are in use or reserved by the system', 'port-taken', + ('The LAN access port cannot be used', '局域网访问的端口无法使用'), + ('The ports for LAN access are in use or reserved by the system. Change the ComfyUI port in Settings, ' + 'then start again.', '局域网访问要用的端口已被占用或被系统保留。请在设置里换一个 ComfyUI 端口,再重新启动。')), (r'The previous video has no usable continuation data', 'continuation-missing', ('This video cannot be continued', '这个视频暂时不能接续'), ('Generate the previous video again, then continue it.', '请重新生成上一段视频,再接续。')), diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 60d998e7..f1a5bdf4 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -465,12 +465,13 @@ ApplicationWindow { text: t("Other disks…", "其他硬盘…"); onClicked: diskDialogOpen = true } FButton { objectName: "diskCleanupButton"; visible: s.failure.kind === "disk"; enabled: s.can_cleanup; text: t("Clean download cache", "清理缓存"); onClicked: { settingsTab = "general"; settingsOpen = true; backend.cleanupDownloads(false) } } FButton { objectName: "changeSource"; visible: s.failure.kind === "download" || s.failure.kind === "model-source"; text: t("Change source", "切换下载源"); onClicked: { settingsTab = "downloads"; settingsOpen = true } } + FButton { objectName: "changeAddress"; visible: s.failure.kind === "port-taken"; text: t("Change address", "更改地址"); onClicked: { settingsTab = "advanced"; settingsOpen = true } } FButton { objectName: "repairCardButton"; primary: true; visible: !!s.repair_offered; text: t("Repair", "修复"); onClicked: backend.action("repair", false) } FButton { objectName: "latestLauncherLink"; visible: s.failure.kind === "older-launcher"; flat: true text: t("Download the latest ↗", "下载最新版 ↗"); onClicked: backend.link("https://github.com/FlashML-org/FreeVideo/releases/latest") } FButton { objectName: "copyError"; visible: !diskFailure; text: t("Copy details", "复制详情"); onClicked: backend.copy(s.error) } FButton { objectName: "showError"; text: errorDetailsOpen ? t("Hide details", "收起详情") : t("Show details", "查看详情"); flat: true; onClicked: errorDetailsOpen = !errorDetailsOpen } - FButton { objectName: "reportProblem"; visible: !diskFailure; text: t("Report a problem", "提交问题"); onClicked: backend.reportProblem(s.error, s.failure.title || "") } + FButton { objectName: "reportProblem"; visible: !diskFailure && s.failure.kind !== "port-taken"; text: t("Report a problem", "提交问题"); onClicked: backend.reportProblem(s.error, s.failure.title || "") } FButton { objectName: "exportError"; visible: !diskFailure; text: t("Export report", "导出报告"); enabled: s.report.status !== "running"; onClicked: backend.exportReport() } } ScrollView { @@ -481,6 +482,7 @@ ApplicationWindow { } FText { + objectName: "reportStatus" visible: s.report.status !== "idle"; Layout.fillWidth: true; font.pixelSize: theme.micro color: s.report.status === "error" ? theme.danger : theme.muted text: s.report.status === "running" ? t("Exporting full logs…", "正在导出完整日志…") : diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index 72674181..cc581f30 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -188,6 +188,7 @@ def source_name(value, zh=False): 'This address does not belong to this computer. Enter one of its IP addresses or 0.0.0.0.': '这个地址不属于本机。请填写本机的 IP 地址或 0.0.0.0。', 'This port and the next ones are in use or reserved by the system. Set a different local ComfyUI address.': '这个端口和后面的端口都已被占用或被系统保留,请换一个本机 ComfyUI 地址。', 'The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.': '局域网访问要用的端口已被占用或被系统保留,请换一个 ComfyUI 端口。', + 'This port is in use by another application. Set a different local ComfyUI address.': '这个端口已被其他程序占用,请换一个本机 ComfyUI 地址。', 'A generation is running. Change the address after it finishes.': '正在生成,完成后可修改地址。', 'The ComfyUI already running does not use this address. After its jobs finish, restart it with this address and connect again.': '已在运行的 ComfyUI 没有使用这个地址。请在任务完成后用这个地址重新启动 ComfyUI,再重新连接。', 'The ComfyUI already running is open to the network directly. After its jobs finish, restart it on 127.0.0.1, then turn on LAN access.': '已在运行的 ComfyUI 正在直接对局域网开放。请在任务完成后改用 127.0.0.1 重新启动它,再开启局域网访问。', diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 7695d907..38c0f2b7 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -9,6 +9,7 @@ import sys import threading import time +import uuid from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from . import __version__ @@ -45,6 +46,28 @@ 'in-use': ('Another program is using it, or this account does not have permission to delete it.', '正被其他程序使用,或当前账户没有删除权限。'), 'failed': ('It could not be deleted.', '未能删除。'), } +# Report exports that failed for a reason the user can act on; anything else keeps its own words. +EXPORT_FAILURES = { + 'exists': ('A file with this name already exists. Choose another name.', '已有同名文件,请换一个文件名。'), + 'permission': ('This folder cannot be written to. Choose another folder.', '该文件夹无法写入,请换一个位置保存。'), + 'in-use': ('The existing file is open in another program or is read-only, so it was not replaced. ' + 'Close that program, or choose another name.', + '原文件正被其他程序打开或为只读,未被替换。请关闭该程序,或换一个文件名。'), + 'disk': ('Not enough disk space. Free up space, then export again.', '磁盘空间不足,请释放空间后再导出。'), +} + + +def export_reason(error): + """The EXPORT_FAILURES key for an export error, or '' when its words are the only explanation.""" + import errno + code, winerror = getattr(error, 'errno', None), getattr(error, 'winerror', None) + if isinstance(error, FileExistsError): + return 'exists' + if code in (errno.ENOSPC, getattr(errno, 'EDQUOT', None)) or winerror in (39, 112): + return 'disk' + if isinstance(error, PermissionError) or code in (errno.EACCES, errno.EPERM, errno.EROFS): + return 'permission' + return '' @@ -1169,7 +1192,8 @@ def diagnostic_error(self): 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): + def export_report(self, output, overwrite=False): + """Write the report ZIP; overwrite replaces a file the save dialog already asked about.""" if self.report['status'] == 'running': return from .diagnostics import collect, Redactor @@ -1182,14 +1206,30 @@ def export_report(self, output): self.report = dict(status='running', path=str(output), error='') def work(): + target = Path(output) + # Collection never writes over a file; a confirmed replacement is built beside it and swapped in whole. + partial = target.with_name('.%s.%s.partial' % (target.name, uuid.uuid4().hex[:8])) if overwrite else target + replacing = False try: - result = collect(root, root / 'machine.json', None, Path(output), + result = collect(root, root / 'machine.json', None, partial, complete=True, extra_files=logs, notes=notes) + if partial != target: + replacing = True + os.replace(io_path(partial), io_path(target)) self.report = dict(status='complete', path=str(output), error='', collection_errors=len(result['errors'])) except Exception as error: - self.report = dict(status='error', path=str(output), + # The new file was already written beside the old one, so the folder is writable: + # Windows refuses the swap when the old file is open elsewhere or read-only. + reason = 'in-use' if replacing and isinstance(error, PermissionError) else export_reason(error) + self.report = dict(status='error', path=str(output), reason=reason, error=Redactor(replacements).text(str(error))) + finally: + if partial != target: + try: + io_path(partial).unlink() + except OSError: + pass threading.Thread(target=work, name='freevideo-export-report', daemon=True).start() @@ -2371,7 +2411,8 @@ def snapshot(self): field_error=dict(self.field_error or {}), retry_kind=self.retry_kind(), notice=self.notice, # Qt reads a list as an array; LEVELS is a tuple. compatibility=dict(self.compatibility, levels=list(self.compatibility.get('levels') or ())), - report=dict(self.report), + report=dict(self.report, error=self.t(*EXPORT_FAILURES[self.report['reason']]) + if self.report.get('reason') in EXPORT_FAILURES else self.report.get('error', '')), models=models, overall=overall, progress=progress, detail=detail, progress_text=file_text, remaining_seconds=remaining, # The elapsed row is only shown while working. An idle clock would diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 5aef4a38..a730092c 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -286,9 +286,11 @@ def exportReport(self): 'freevideo-diagnostics-' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '.zip', 'ZIP (*.zip)') if path: - if not path.lower().endswith('.zip'): + # The dialog asked before replacing the name it returned; a name we extend was never asked about. + confirmed = path.lower().endswith('.zip') + if not confirmed: path += '.zip' - self.invoke(session.export_report, path) + self.invoke(session.export_report, path, confirmed) @Slot(str) def link(self, value): diff --git a/web/generation_progress.js b/web/generation_progress.js index 51b7ff7a..def8c135 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -1,5 +1,5 @@ // One whole-video estimate and the same five rows in Studio and the node view. -import { saveReport, reportIssue, showReportTip, focusReportTip } from './report_issue.js'; +import { saveReport, reportIssue, showReportTip, focusReportTip, REPORT_GONE, reportFetchError } from './report_issue.js'; import { issueButton } from './issue_button.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./generation_progress.css', import.meta.url).href; @@ -386,6 +386,7 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t if (message.new_request === true || message.overall?.reset === true || message.reset === true && !message.phase) { problem?.remove(); reportId = null; revision++; report.hidden = true; pinned = false; pinnedBlob = null; + report.disabled = false; report.textContent = label(); } if (typeof message.report_id === 'string' && /^[a-f0-9]{32}$/.test(message.report_id)) { if (reportId !== message.report_id) { problem?.remove(); revision++; pinned = false; pinnedBlob = null; report.disabled = false; report.textContent = label(); } @@ -399,7 +400,7 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t if (pinned && pinnedBlob) return pinnedBlob; const id = reportId, path = `/freevideo/report/${id}`; const response = await (api ? api.fetchApi(path) : fetch(path)); - if (!response.ok) throw new Error('Report unavailable'); + if (!response.ok) throw reportFetchError(response); const blob = await response.blob(); if (pinned && reportId === id) pinnedBlob = blob; return blob; @@ -414,6 +415,7 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t problem?.remove(); const current = revision; report.disabled = true; report.textContent = t('Preparing report…', '正在整理报告…'); + let gone = false; try { await downloadReport(); if (current === revision) { @@ -421,13 +423,15 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t if (tip && report.isConnected) showReportTip(report.parentElement, report, t, {mode: 'downloaded', submit: () => reportIssue(fetchReport, typeof version === 'function' ? version() : version, {save: false})}); } - } catch { - if (current === revision) { + } catch (error) { + gone = Boolean(error?.gone); + if (current === revision && gone) report.textContent = t(...REPORT_GONE); + else if (current === revision) { report.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); problem = issueButton(t, () => ({where: ['Diagnostic report', '诊断报告'], message: report.textContent})); report.after(problem); } - } finally { if (current === revision) report.disabled = false; } + } finally { if (current === revision) report.disabled = gone; } }; return {report, updateReport, fetchReport, downloadReport, hasReport: () => reportId !== null, pin: () => { pinned = true; }}; } diff --git a/web/image_generation.js b/web/image_generation.js index e7a18ba8..94cdc3ff 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -2,7 +2,7 @@ // 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, focusReportTip} from './report_issue.js'; +import {saveReport, reportIssue, showReportTip, focusReportTip, REPORT_GONE, reportFetchError} from './report_issue.js'; import {issueButton} from './issue_button.js'; import {about} from './request_assets.js'; import {createImageLoras} from './image_lora.js'; @@ -220,6 +220,7 @@ export const REPORT_LABELS = Object.freeze({ description: ['Diagnostic report: hardware, settings and timings', '诊断报告:硬件、设置和耗时'], preparing: ['Preparing…', '正在整理…'], unavailable: ['Report unavailable · retry', '报告暂不可用 · 重试'], + gone: REPORT_GONE, }); // Shared by the editor and the other image entry points. Only the opaque report @@ -240,7 +241,7 @@ export function renderImageFailure(host, {error, report_id, stage, fitted, info, 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'); + if (!response.ok) throw reportFetchError(response); return response.blob(); }; const version = () => typeof productVersion === 'function' ? productVersion() : productVersion; @@ -256,14 +257,17 @@ export function renderImageFailure(host, {error, report_id, stage, fitted, info, const restoreFocus = doc.activeElement === anchor; issue.hidden = true; anchor.disabled = true; anchor.textContent = t(...REPORT_LABELS.preparing); + let gone = false; 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); issue.hidden = false; } - finally { - anchor.disabled = false; + } catch (error) { + gone = Boolean(error?.gone); + anchor.textContent = t(...(gone ? REPORT_LABELS.gone : REPORT_LABELS.unavailable)); issue.hidden = gone; + } finally { + anchor.disabled = gone; if (restoreFocus && card.isConnected && doc.activeElement === doc.body) anchor.focus({preventScroll: true}); } }; diff --git a/web/report_issue.js b/web/report_issue.js index 6e7a5f73..f4eca45f 100644 --- a/web/report_issue.js +++ b/web/report_issue.js @@ -6,6 +6,11 @@ const TIP_KEY = 'freevideo.report-tip'; const gb = bytes => Number.isFinite(bytes) && bytes > 0 ? Math.round(bytes / 2 ** 30) : null; +// A 404 means the server no longer keeps this run's report (FreeVideo restarted, or many runs followed): +// asking again cannot bring it back, so the button says so and stays off, with nothing to report. +export const REPORT_GONE = ['Report expired', '报告已过期']; +export const reportFetchError = response => Object.assign(new Error('Report unavailable'), {gone: response?.status === 404}); + // The issue form, prefilled from the redacted report. The report carries no // prompt, reference or video, so neither does the address. export function reportIssueURL(report, productVersion = null) { @@ -225,7 +230,10 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = if (send.disabled) return; send.disabled = true; sendText.textContent = t('Preparing…', '正在整理…'); try { const address = await submit(); closeForGood(); sent(address); } - catch { send.disabled = false; sendText.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); } + catch (error) { + send.disabled = Boolean(error?.gone); + sendText.textContent = error?.gone ? t(...REPORT_GONE) : t('Report unavailable · retry', '报告暂不可用 · 重试'); + } }; row.append(send); text.append(title, lead, note); diff --git a/web/result_actions.js b/web/result_actions.js index fdc92efa..c5e0798e 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -4,6 +4,7 @@ import { shareButton } from './share.js'; import { canExtend, extendText, extendSourceProblem, extendSourceState } from './studio_extend.js'; import { issueButton } from './issue_button.js'; import { savedSettings, differs } from './result_reuse.js'; +import { REPORT_GONE } from './report_issue.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./result_actions.css', import.meta.url).href; document.head.append(css); @@ -287,13 +288,17 @@ export function resultActions(record, t, {diagnostic = null, secondPass = null, if (report.disabled) return; problem?.remove(); report.disabled = true; text.textContent = t('Preparing…', '正在整理…'); + let gone = false; try { await diagnostic(); text.textContent = reportLabel; } - catch { - text.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); - problem = issueButton(t, () => ({where: ['Diagnostic report', '诊断报告'], message: text.textContent})); - report.after(problem); + catch (error) { + gone = Boolean(error?.gone); + text.textContent = gone ? t(...REPORT_GONE) : t('Report unavailable · retry', '报告暂不可用 · 重试'); + if (!gone) { + problem = issueButton(t, () => ({where: ['Diagnostic report', '诊断报告'], message: text.textContent})); + report.after(problem); + } } - finally { report.disabled = false; } + finally { report.disabled = gone; } }; bar.append(report); } diff --git a/web/setup.js b/web/setup.js index 9aba385a..8670141f 100644 --- a/web/setup.js +++ b/web/setup.js @@ -18,9 +18,36 @@ 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 SPEED_TEST_FAILED = 'The speed test did not finish. Click "Test source speeds".'; -const reportable = value => !/The installation plan changed\. Detect configuration again|MODEL_MISMATCH_REPAIR:/.test(String(value || '')); +// Setup refusals whose cause and next step are known. Repair goes where the server's sentence pointed: the launcher when it watches. +const repairStep = text => text.includes('In the FreeVideo launcher, open Settings and click Repair.') + ? t('In the FreeVideo launcher, open Settings and click Repair.', '请在 FreeVideo 启动器的设置里点“修复”。') + : t('Click Detect & review, then Install / repair.', '请先点“检测并预览”,再点“安装/修复”。'); +const SETUP_ERRORS = [ + [/^Choose an engine installation folder$/, () => t('Choose a FreeVideo installation folder first.', '请先选择 FreeVideo 安装目录。')], + [/^Use a dedicated FreeVideo folder, not the ComfyUI root or its parent$/, + () => t('Choose a separate folder for FreeVideo, not the ComfyUI folder or the folder that contains it.', + '请给 FreeVideo 单独选一个文件夹,不要用 ComfyUI 文件夹或它的上一级。')], + [/^Finish or cancel queued ComfyUI requests before installing$/, + () => t('Generations are running or queued. Install after they finish, or cancel them first.', '仍有生成正在进行或排队。请等待完成或先取消,再安装。')], + [/^Finish or cancel queued ComfyUI requests before calibrating$/, + () => t('Generations are running or queued. Run the test after they finish, or cancel them first.', '仍有生成正在进行或排队。请等待完成或先取消,再运行放置测试。')], + [/^Wait for setup to finish$/, () => t('Installation is still running. Try again after it finishes.', '安装仍在进行,请在完成后重试。')], + [/^Inspect this installation again before installing$/, () => t('Click Detect & review again, then Install / repair.', '请重新点“检测并预览”,再点“安装/修复”。')], + [/^Review the plan and accept its model\/toolkit licenses first$/, () => t('Tick the agreement above to enable Install / repair.', '勾选上方的同意选项后,可点击“安装/修复”。')], + [/^FreeVideo setup is missing\./, text => t('This folder has no finished FreeVideo installation. ', '该文件夹中没有已安装的 FreeVideo。') + repairStep(text)], + [/^FreeVideo setup is incomplete in /, text => t('The installation in this folder is not finished. ', '该文件夹中的安装尚未完成。') + repairStep(text) + + t(' Existing files are reused.', '已有文件会继续使用。')], + [/^FreeVideo device configuration is missing\./, text => t('This installation has not checked the GPU yet. ', '该安装尚未检查显卡。') + repairStep(text)], + [/^The FreeVideo installation was moved\./, text => t('This installation was moved from another folder. ', '该安装是从其他文件夹移动过来的。') + repairStep(text)], + [/^The FreeVideo Python environment is missing\./, text => t('This installation is missing its Python environment. ', '该安装缺少 Python 环境。') + repairStep(text)], +]; +const setupError = text => SETUP_ERRORS.find(([pattern]) => pattern.test(text))?.[1](text); +const reportable = value => !/The installation plan changed\. Detect configuration again|MODEL_MISMATCH_REPAIR:/.test(String(value || '')) + && setupError(String(value || '')) === undefined; const failureText = value => { const text = String(value || ''), matches = [...text.matchAll(/MODEL_MISMATCH_REPAIR: ([^\r\n]+)/g)]; + const known = setupError(text); + if (known !== undefined) return known; if (!matches.length && text.includes('The installation plan changed. Detect configuration again')) { const files = text.includes('A file in the installation folder changed after the review'); return files @@ -407,7 +434,9 @@ export async function openSetup() { catch (error) { fail(error); } }; use.onclick = async () => { - try { const result = await request("use", {root: root.value}); ready = Boolean(result.ready); state.textContent = result.ready ? t("Existing installation connected. Ready to generate.", "已连接现有安装,可以生成。") : result.detail; state.className = "fv-ready"; invalidate(); } + try { const result = await request("use", {root: root.value}); ready = Boolean(result.ready); if (result.ready) { state.textContent = t("Existing installation connected. Ready to generate.", "已连接现有安装,可以生成。"); state.className = "fv-ready"; } + else { fault(state, result.detail); state.className = "fv-failure"; } + invalidate(); } catch (error) { fail(error); } }; cancel.onclick = async () => { try { render(await request("cancel")); poll(); } catch (error) { fail(error); } }; From 8419791d75aef8f9c013c6d2fe798f8b51522424 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 16/26] Setup: report the tool's own reason when the isolated Python install fails --- freevideo.ps1 | 6 +++++- freevideo_engine/failure_details.py | 2 +- freevideo_engine/windows_bootstrap.ps1 | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/freevideo.ps1 b/freevideo.ps1 index 5a3e24a8..468d1149 100644 --- a/freevideo.ps1 +++ b/freevideo.ps1 @@ -137,10 +137,14 @@ try { if ($pythonExit -eq 0) { $installed = $true; break } if (-not (Test-FreeVideoSlowDownloadFailure $pythonOutput)) { $allNetworkFailures = $false } Write-Host "Python install source $attempt returned exit code $pythonExit. Details retained in $bootstrapLog" + $pythonReason = Get-FreeVideoFailureReason $pythonOutput + if ($pythonReason) { Write-Host "uv reported: $pythonReason" } } } } - if (-not $installed) { throw "Python installation failed (uv exit $pythonExit). Check your network or connection mode in Downloads. See $bootstrapLog and retry." } + $pythonDetail = '' + if ($pythonReason) { $pythonDetail = " uv reported: $pythonReason." } + if (-not $installed) { throw "Python installation failed (uv exit $pythonExit).$pythonDetail Check your network or connection mode in Downloads. See $bootstrapLog and retry." } $freevideoPython = (& $uv python find --managed-python $versions.python | Select-Object -Last 1).Trim() if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $freevideoPython)) { throw 'Managed Python could not be located.' } } finally { diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index a62984ca..0e41438a 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -860,7 +860,7 @@ def _launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_re lines = [line.strip() for line in text.splitlines() if line.strip()] causes = [line for line in lines if re.match(r'^(?:[\w.]+(?:Error|Exception):|ERROR[: ]|Failed step:|Setup failed:)', line)] detail = causes[-1] if causes else next((line for line in reversed(lines) - if not line.startswith(('File ', 'Traceback', '^', 'All files retained.', 'Rerun the same setup command'))), '') + if not line.startswith(('File ', 'Traceback', '^', 'All files retained.', 'Existing files are retained.', 'Rerun the same setup command'))), '') detail = re.sub(r'^(?:ValueError|RuntimeError|OSError):\s*', '', detail) from .launcher_copy import lan_error title = '暂时未能完成' if zh else 'Couldn’t complete this step' diff --git a/freevideo_engine/windows_bootstrap.ps1 b/freevideo_engine/windows_bootstrap.ps1 index b7cc3e9f..b6f9acbb 100644 --- a/freevideo_engine/windows_bootstrap.ps1 +++ b/freevideo_engine/windows_bootstrap.ps1 @@ -64,6 +64,16 @@ function Invoke-FreeVideoBootstrapCommand { } } +function Get-FreeVideoFailureReason { + param([string]$Output) + # The last two lines of a child's output on one short line, so the console and + # the setup report carry what the tool said, not only its exit code. + $lines = @($Output -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Last 2) + $reason = $lines -join ' | ' + if ($reason.Length -gt 400) { $reason = $reason.Substring(0, 400) } + return $reason +} + function Test-FreeVideoSlowDownloadFailure { param([string]$Detail) # Keep the patient-round gate aligned with network.slow_download_failure. From cf0578b40edbeee921d809cb048a7c8b61635455 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 17/26] Per-GPU queue: admit studio and Agent requests, and let a lone oversized task run exclusively --- freevideo_engine/gpu_scheduler.py | 7 +++++- freevideo_engine/pool_resources.py | 36 +++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/freevideo_engine/gpu_scheduler.py b/freevideo_engine/gpu_scheduler.py index b07baa9b..63eb6a6a 100644 --- a/freevideo_engine/gpu_scheduler.py +++ b/freevideo_engine/gpu_scheduler.py @@ -129,7 +129,12 @@ def dispatch(self) -> list[tuple[Task, Device]]: if self._running: break elif not self._ledger.try_reserve(task.id, task.envelope): - continue + if self._running or binding is not None: + continue + # Alone on an idle host the loose whole-task envelope may exceed + # every byte the machine has. The task then runs exclusively, as on + # a single card, and its own planner admits or refuses it. A retry keeps waiting. + task = replace(task, envelope=None) self._running[task.id] = (task, device) self._bindings[task.id] = device.uuid del self._pending[task.id] diff --git a/freevideo_engine/pool_resources.py b/freevideo_engine/pool_resources.py index 1b213898..c90eff5c 100644 --- a/freevideo_engine/pool_resources.py +++ b/freevideo_engine/pool_resources.py @@ -96,6 +96,10 @@ def _checkpoint_storage(path, expected): return expected, expanded +# Longest request one prompt can carry: a 30 s video, the chain planner's first split. +MAX_FRAMES = 721 + 22 + + class PromptEnvelope: """Estimate literal, text-only FreeVideo requests up to the policy reference. @@ -122,34 +126,38 @@ def _estimate(self, prompt): if len(generators) != 1: return None node = generators[0] - empty_media = set() + media_nodes, assets = set(), [] for identity, other in prompt.items(): if other is node: continue media = other.get('inputs', {}) if (other.get('class_type') != 'FreeVideoMedia' or set(media) - {'assets'} - or not isinstance(media.get('assets', '[]'), str) - or json.loads(media.get('assets', '[]')) != []): + or not isinstance(media.get('assets', '[]'), str)): return None - empty_media.add(identity) + rows = json.loads(media.get('assets', '[]')) + if (not isinstance(rows, list) or len(rows) > 32 + or any(not isinstance(row, dict) or not isinstance(row.get('file'), str) for row in rows)): + return None + assets += [row for row in rows if row.get('enabled', True) is True] + media_nodes.add(identity) inputs = dict(node['inputs']) link = inputs.get('media') - if isinstance(link, list) and len(link) == 2 and link[0] in empty_media and link[1] == 0: + if isinstance(link, list) and len(link) == 2 and link[0] in media_nodes and link[1] == 0: del inputs['media'] + elif link is not None or media_nodes: + return None allowed = {'text', 'width', 'height', 'seconds', 'seed', 'two_pass', 'base_steps', 'refine_steps', 'force_regenerate', 'preview', 'frames', 'step_cache', 'upscale_factor', 'refine_from', 'continue_from', 'first_frame_reference', - 'voice_reference', 'hide_tail'} + 'voice_reference', 'hide_tail', 'allow_empty_prompt'} if (set(inputs) - allowed or any(isinstance(v, (list, dict)) for v in inputs.values()) - or inputs.get('refine_from') or inputs.get('continue_from') - or inputs.get('hide_tail') or inputs.get('first_frame_reference') - or inputs.get('voice_reference') or inputs.get('step_cache', 'none') != 'none'): + or inputs.get('refine_from') or inputs.get('step_cache', 'none') != 'none'): return None from .geometry import geometry canvas = geometry(inputs.get('width', 1344), inputs.get('height', 768), frames=inputs['frames'] if inputs.get('frames') else None, seconds=None if inputs.get('frames') else inputs.get('seconds', 10.)) - if canvas['width'] > 1344 or canvas['height'] > 768 or canvas['frames'] > 243: + if (canvas['width'] * canvas['height'] > 1920 * 1088 or canvas['frames'] > MAX_FRAMES): return None machine = json.loads((self.root / 'machine.json').read_text(encoding='utf-8')) manifest = json.loads(Path(__file__).with_name('model_files.json').read_text(encoding='utf-8')) @@ -207,6 +215,14 @@ def _estimate(self, prompt): workspace = (HOST_WEIGHT_HEADROOM + ENCODER_HOST_BYTES + residual + decoder_workspace(canvas) + 3 * output + UPSCALER_HOST_BYTES + adaln_extra_bytes(canvas)) + # Every picture, clip or sound keeps a full set of decoded frames and may add + # a reference residual of its own. A continued part also decodes its overlap. + from .comfy_assets import media_kind + for row in assets: + kind = media_kind(row['file']) + workspace += output + (0 if kind == 'audio' else residual) + (2 * output if kind == 'video' else 0) + if inputs.get('continue_from'): + workspace += output # Charge every private/workspace byte as potentially pinned, including # attention readouts and decoder/encoder transfer buffers. pinned = expanded + prepared + workspace From 2d7625cb5c1a9eb57b6aded8aef83acebe6b9903 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 18/26] Remove short folders whose runtime folder no longer exists --- freevideo_engine/runtime_alias.py | 63 ++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/runtime_alias.py b/freevideo_engine/runtime_alias.py index 599e598d..43f55e7b 100644 --- a/freevideo_engine/runtime_alias.py +++ b/freevideo_engine/runtime_alias.py @@ -719,6 +719,14 @@ def _starts_as_it_is(root): and path_chars(root) + 1 + _longest(root, PROGRAMS) <= WINDOWS_PATH_LIMIT) +@lru_cache(maxsize=1) +def _reclaim_once(): + try: + reclaim_views() + except Exception: # Tidying up must never stop a launch. + pass + + def runtime_view(root, longest=None) -> Path: if not _backend.windows(): return Path(root) @@ -728,7 +736,9 @@ def runtime_view(root, longest=None) -> Path: return root try: with _backend.coordinate(root): - return _runtime_view(root) + view = _runtime_view(root) + _reclaim_once() + return view except OSError: # No short folder can be made. A runtime whose programs start from where they are # runs as it did before there was a short folder; only one that cannot is refused. @@ -795,6 +805,57 @@ def _runtime_view(root): raise AliasUnavailable(reason, last) from last +def _orphan_target(alias): + """The folder a short folder of ours points to, when that folder is gone.""" + with _backend.parent(alias.parent): + io = _backend.io(alias) + if not _backend.junction(io): + return None + target = _ordinary(_backend.readlink(io)) + if not target.is_absolute() or not any(_normal(alias) == _normal(item) for item in _candidates(target, alias.parent)): + return None + try: + # Only a folder that is missing from a place that can be read counts as gone. + if not _backend.io(target.parent).is_dir(): + return None + _backend.io(target).lstat() + except FileNotFoundError: + return target + except (OSError, ValueError): + pass + return None + + +def reclaim_views(): + """Remove short folders whose runtime folder no longer exists; returns how many.""" + if not _backend.windows(): + return 0 + removed = 0 + for parent in _backend.locations(): + parent = _ordinary(parent) + try: + if is_junction(parent): + continue + names = [name for name in os.listdir(_backend.io(parent)) if _NAME.fullmatch(name)] + except (OSError, ValueError): + continue + for name in names: + alias = parent / name + try: + target = _orphan_target(alias) + if target is None: + continue + # Look again under the lock that creating a view of this folder takes: + # a reinstall may have brought the folder back since the first look. + with _backend.coordinate(target): + if _orphan_target(alias) == target and _backend.remove(alias, target): + removed += 1 + except (OSError, ValueError): + # Busy, unreadable or changed objects remain untouched. + pass + return removed + + def drop_view(root): if not _backend.windows(): return From 0cea53ac03bde8313ef3dc1becd754135a682098 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 19/26] The uv tool is fetched again when it has gone missing before an install step --- freevideo_engine/bootstrap.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 368106c4..a42794dd 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -1234,6 +1234,8 @@ def reusable_kernel_receipt(path, hardware_data): class Installer: def __init__(self, value, ui=None): self.locks = ExitStack() + self.uv_lock = threading.Lock() + self.uv_failure = None try: self.initialize(value, ui) except BaseException: @@ -1470,6 +1472,32 @@ def feedback(row): raise self.ui.end(key, detail='Verified and ready') + def ensure_uv(self): + """Fetch uv again when security software or a cleaner removed it after it was prepared.""" + uv = Path(self.env['FREEVIDEO_UV']) + def usable(): + try: + return uv.is_file() and uv.stat().st_size > 0 + except OSError: + return False + if usable(): + return uv + with self.uv_lock: + # One attempt per installation: concurrent steps and later commands report the same failure. + if self.uv_failure: + raise RuntimeError(self.uv_failure) + if not usable(): + self.ui.event('setup_cache', key='uv', detail='Downloading the uv tool again; it was missing') + try: + self.env['FREEVIDEO_UV'] = str(self.prepare_tools()) + except BaseException: + self.uv_failure = 'The uv tool is missing at %s and could not be restored.' % uv + raise + if not usable(): + self.uv_failure = 'The uv tool is missing at %s and could not be restored.' % uv + raise RuntimeError(self.uv_failure) + return uv + def command(self, label, args, env=None, cwd=None): from .ram import ProcessMemory from .package_progress import PackageOutput @@ -1480,6 +1508,8 @@ def command(self, label, args, env=None, cwd=None): env = dict(env, FREEVIDEO_INSTALL_PROGRESS='1') env = with_private_compiler(self.root, env) args, cwd = uv_file_arguments(args, cwd) + if args and str(args[0]) == self.env.get('FREEVIDEO_UV'): + self.ensure_uv() env = uv_build_environment(args, self.root, env) with self.state_lock: index = len(self.state['steps']) @@ -1792,6 +1822,7 @@ def python_install(self, uv): # and unpack another copy. Existing installations keep their own base. if prepared.is_dir() and not any(current.glob('cpython-' + self.versions['python'] + '-*')): env = dict(self.env, UV_PYTHON_INSTALL_DIR=str(prepared)) + uv = self.ensure_uv() if str(uv) == self.env.get('FREEVIDEO_UV') else uv found = subprocess_output.run_external([str(uv), '--no-config', 'python', 'find', '--managed-python', '--no-python-downloads', self.versions['python']], env=env, capture_output=True, timeout=30) From ed2dd58208a54e04d0c0fe8f72bbd01211536ea5 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 20/26] Hold one stream-sized buffer at the staged residual boundaries --- freevideo_engine/residual.py | 83 ++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/residual.py b/freevideo_engine/residual.py index 64b2ac51..29f645f1 100644 --- a/freevideo_engine/residual.py +++ b/freevideo_engine/residual.py @@ -7,6 +7,12 @@ import time import torch +# Rows handled per step of the tiled sublayer boundaries below. A bounded +# block holds the residual stream on the card only between the host copies, so +# a whole-stream normalization output or restored copy next to the stream +# itself doubles the largest activation. Tiles keep one stream-sized buffer. +ROW_TILE = 8192 + class ResidualState: def __init__(self, tensor): @@ -113,6 +119,57 @@ def restore(self): self.copy_wall_seconds += time.perf_counter()-tick return tensor + def tiled(self): + """True when the stream has more rows than one tile.""" + return self.shape[-2] > ROW_TILE + + def store_through(self, tensor, transform): + """Stage `tensor` on the host, then overwrite its rows with `transform`. + + `transform(rows, start, stop)` maps a tile of rows to its replacement of + the same shape. The staged copy of each tile is queued before that tile + is overwritten on the same stream, so the host receives the original + rows and the device buffer ends up holding the transformed ones, with + no second stream-sized buffer. Row-wise kernels give every row the same + bytes whatever the tile boundaries (row_kernels). + """ + self._check(tensor) + if self.tensor is not None: + raise RuntimeError('Consume the device residual before staging it') + tick = time.perf_counter() + if self.host is None: + self.host = self._allocate() + for start in range(0, self.shape[-2], ROW_TILE): + stop = min(start + ROW_TILE, self.shape[-2]) + rows = tensor[..., start:stop, :] + self.host[..., start:stop, :].copy_(rows, non_blocking=self.pinned) + rows.copy_(transform(rows, start, stop)) + self.stored = True + self.copies['to_host'] += 1 + self.copy_wall_seconds += time.perf_counter()-tick + return tensor + + def restore_into(self, branch, combine): + """Combine the staged stream with `branch` tile by tile, in place. + + `combine(residual_rows, start, stop, branch_rows)` writes into + `branch_rows` (a view of `branch`); the staged rows reach the device one + tile at a time instead of as a second stream-sized copy. + """ + if self.closed or not self.stored: + raise RuntimeError('No complete host residual is available') + if tuple(branch.shape) != self.shape or branch.dtype != self.dtype or branch.device != self.device: + raise ValueError('Residual shape, dtype or device changed') + tick = time.perf_counter() + for start in range(0, self.shape[-2], ROW_TILE): + stop = min(start + ROW_TILE, self.shape[-2]) + rows = self.host[..., start:stop, :].to(self.device, copy=True, non_blocking=self.pinned) + combine(rows, start, stop, branch[..., start:stop, :]) + del rows + self.copies['to_device'] += 1 + self.copy_wall_seconds += time.perf_counter()-tick + return branch + def replace(self, tensor): self._check(tensor) if self.tensor is not None: @@ -139,17 +196,37 @@ def forward(self, state, temb, adaln_indices, rotary_emb, attention_mask=None): raise RuntimeError('Residual offload requires inference without autograd') if not isinstance(state, ResidualState): raise TypeError('Residual offload needs the owning packed-input container') + tiled = state.tiled() hidden = state.take() shift_a, scale_a, gate_a, shift_f, scale_f, gate_f = self.adaln_proj(temb) - normalized = pre(hidden, self.norm1.weight, self.norm1.eps, scale_a, shift_a, adaln_indices) - state.store(hidden) - del hidden + if tiled: + # The normalized rows replace the stream rows once they are staged. + normalized = state.store_through(hidden, lambda rows, start, stop: pre( + rows, self.norm1.weight, self.norm1.eps, scale_a, shift_a, adaln_indices[start:stop])) + del hidden + else: + normalized = pre(hidden, self.norm1.weight, self.norm1.eps, scale_a, shift_a, adaln_indices) + state.store(hidden) + del hidden if state.collect_after_store: state.collect_after_store = False import gc gc.collect() branch = self.attn(normalized, rotary_emb, attention_mask) del normalized + if tiled: + hidden = state.restore_into(branch, lambda residual, start, stop, rows: post( + residual, gate_a, adaln_indices[start:stop], rows)) + del branch + normalized = state.store_through(hidden, lambda rows, start, stop: pre( + rows, self.norm2.weight, self.norm2.eps, scale_f, shift_f, adaln_indices[start:stop])) + del hidden + branch = self.ff(normalized) + del normalized + state.replace(state.restore_into(branch, lambda residual, start, stop, rows: post( + residual, gate_f, adaln_indices[start:stop], rows))) + del branch + return state residual = state.restore() hidden = post(residual, gate_a, adaln_indices, branch) del residual, branch From 13a9fe9197a14b2077e6b5712a75e689e4e2519d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 21/26] Leave allocator slack beside kept models before decoding, dropping decoder blocks first --- freevideo_engine/resident_models.py | 31 ++++++++++++++++++++++++++++- freevideo_engine/worker.py | 14 +++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/resident_models.py b/freevideo_engine/resident_models.py index 7f03200a..b275749c 100644 --- a/freevideo_engine/resident_models.py +++ b/freevideo_engine/resident_models.py @@ -12,6 +12,10 @@ from .device_index import device_properties GiB = 1 << 30 +# Allocator overhead beside a measured decode peak: 5% reserved but unallocated at the limit +# plus a split block of one clip tile, on a 960x544 decode that failed with 4% room left. +DECODE_SLACK_BYTES = 256 << 20 +DECODE_SLACK_FRACTION = 0.1 # Where a preview's sampling peak is kept, apart from its complete request's. PREVIEW_STAGE = 'engine_preview' HABIT_WINDOW = 4 @@ -652,9 +656,34 @@ def decoder_room(self, options, canvas): + 4*GiB) else: need = 15*GiB + max(0, (canvas.get('video_tokens', 72576)-72576)*30000) - need = self.peaks.get(('decode', key(dict(options=options, canvas=canvas))), need) + observed = self.peaks.get(('decode', key(dict(options=options, canvas=canvas)))) + if observed is not None: + need = observed + if options.get('offload'): + options, need = self.fit_observed_decoder(options, observed) self.make_room('video_vae', min(self.gpu_budget, need), model=self.entries.get('video_vae', {}).get('model'), ram_need=2*GiB) + return options + + def fit_observed_decoder(self, options, observed): + """Options and peak for a decoder that has run before, with the allocator's slack. + + The models kept for the next request are admitted against the measured + peak, which leaves out the caching allocator's own overhead: a peak + that only just fits leaves the allocator fragmented at its limit and + the first clip fails. Leave that slack by dropping resident decoder + blocks first, which only moves weight transfers and decodes the same + values, so a kept model is evicted only when no block is left to drop. + """ + slack = max(DECODE_SLACK_BYTES, int(observed * DECODE_SLACK_FRACTION)) + planned = options.get('resident_blocks', 0) + if planned: + from .policy import decoder_block_bytes + block = decoder_block_bytes(linear_compute_cache=bool(options.get('linear_compute_cache'))) + cut = min(planned, max(0, -(-(observed + slack - self.available()) // block))) + if cut: + options, observed = dict(options, resident_blocks=planned - cut), observed - cut * block + return options, observed + slack def snapshot(self): if not torch.cuda.is_initialized(): diff --git a/freevideo_engine/worker.py b/freevideo_engine/worker.py index ea7fa7b4..954a086c 100644 --- a/freevideo_engine/worker.py +++ b/freevideo_engine/worker.py @@ -220,6 +220,16 @@ def fit_decoder(): decoder = dict(decoder, resident_blocks=fit) metrics.setdefault('decoder_residency', dict(planned_resident_blocks=planned)) metrics['decoder_residency'].update(resident_blocks=fit, allocator_limit_bytes=int(limit)) + def room_decoder(): + # Models kept for the next request stay only where the decoder still fits beside them with + # the allocator's slack; the room step drops resident blocks before it evicts one. + nonlocal decoder + planned = decoder.get('resident_blocks', 0) + fitted = resident.decoder_room(decoder, canvas) + if isinstance(fitted, dict) and fitted.get('resident_blocks', 0) < planned: + decoder = fitted + metrics.setdefault('decoder_residency', dict(planned_resident_blocks=planned)) + metrics['decoder_residency'].update(resident_blocks=fitted['resident_blocks'], room='kept models') resume = request.get('resume_decode') is not None if not resume and (options.get('task', 't2va') != 't2va' or request.get('geometry', {}).get('early_reference')): from .media_conditioning import describe @@ -291,7 +301,7 @@ def capture_first(video): print(json.dumps(dict(event='decode_resume', sampling_source_attempt=metrics['sampling_source_attempt'])), flush=True) fit_decoder() if resident is not None: - resident.decoder_room(decoder, canvas) + room_decoder() latents, audio = latents.to('cuda'), audio.to('cuda') metrics['latent_reload_seconds'] = time.perf_counter() - tick metrics['transformer_released_before_decode'] = resident is None or not resident.engine_on_gpu() @@ -637,7 +647,7 @@ def refined_complete(video, sound, receipt): # keeps it if live memory also covers decoding, otherwise evicts it. fit_decoder() if resident is not None: - resident.decoder_room(decoder, canvas) + room_decoder() else: engine.close() metrics['transformer_released_before_decode'] = released(engine) From 53a5cc58fb18c42f2e0ddc63f0e6106d9b82875e Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 02:31:21 +0000 Subject: [PATCH 22/26] Prompt enhancement fits time points that run past the end of the video --- freevideo_engine/prompt_vlm/rules.py | 26 ++++++++++++++- freevideo_engine/prompt_vlm/service.py | 2 +- freevideo_engine/prompt_vlm/worker.py | 46 ++++++++++++++++++++------ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py index 2c6384c4..bb1e8eaf 100644 --- a/freevideo_engine/prompt_vlm/rules.py +++ b/freevideo_engine/prompt_vlm/rules.py @@ -170,7 +170,7 @@ def validate_output(text, value): section = text.split(marker + ':', 1)[1].split('\n\n', 1)[0].strip() if not section: raise InvalidOutput('empty_section', text, value) - for minutes, seconds in re.findall(r'\bAt (\d{2}):(\d{2}\.\d{3})', text): + for minutes, seconds in TIME_POINT.findall(text): if int(minutes) * 60 + float(seconds) >= value['seconds']: raise InvalidOutput('timestamp_not_before_duration', text, value) # Only the labels the scene itself uses: the media given, plus audio and connected media it cannot see. @@ -352,6 +352,30 @@ def missing_lines(text, scene): DIALOGUE_RETRY = ('Your rewrite left out these spoken lines. Rewrite the whole prompt again in the same format and ' 'include each line once, verbatim, at the moment it is spoken, as (S1) [Language] ...:\n') +TIME_POINT = re.compile(r'\bAt (\d{2}):(\d{2}\.\d{3})') +# A rewritten prompt that keeps time points from an earlier, longer version ("At 00:07.000" in a 5 s video) +# is rejected; one correction turn asks for the same story fitted inside the requested length. +TIMESTAMP_RETRY = ('Your rewrite has time points at or after the end of the %g-second video. Rewrite the whole prompt ' + 'again in the same format with the same story fitted inside %g seconds: every "At MM:SS.mmm" time ' + 'point must be earlier than %g seconds, so move later actions earlier or merge them. Keep every ' + 'other detail and the required section format.\n') + + +def fit_time_points(text, seconds): + """The same text with every "At MM:SS.mmm" scaled so the latest one falls at 90% of the video, order kept. + + Used only for a rewrite that was rejected for time points at or after the end of the video and still has + them after the correction turn; the story and every other word are untouched. + """ + points = TIME_POINT.findall(text) + latest = max((int(minutes) * 60 + float(second) for minutes, second in points), default=0.) + if latest < seconds: + return text + def scaled(match): + milliseconds = round((int(match.group(1)) * 60 + float(match.group(2))) * .9 * seconds / latest * 1000) + return 'At %02d:%06.3f' % (milliseconds // 60000, milliseconds % 60000 / 1000) + return TIME_POINT.sub(scaled, text) + def user_text(value): """The user's scene, plus the spoken lines the rewrite must keep verbatim.""" diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index 423248fa..430b1b45 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -617,7 +617,7 @@ def read_rows(): except rules.InvalidOutput as error: stats = result.get('diagnostics') or {} error.details['retried'] = bool(stats.get('media_retry') or stats.get('picture_retry') - or stats.get('dialogue_retry')) + or stats.get('dialogue_retry') or stats.get('timestamp_retry')) self.progress(dict(diagnostics=stats)) raise self.progress(dict(result, worker_returncode=child.returncode)) diff --git a/freevideo_engine/prompt_vlm/worker.py b/freevideo_engine/prompt_vlm/worker.py index fe358a04..508621fd 100644 --- a/freevideo_engine/prompt_vlm/worker.py +++ b/freevideo_engine/prompt_vlm/worker.py @@ -387,7 +387,7 @@ def rewrite(folder, value, stats): persistent_model=False) torch, checkpoint = begin(stats) from transformers import AutoProcessor - from .rules import DIALOGUE_RETRY, InvalidOutput, instruction, label, language, missing_lines, user_text + from .rules import DIALOGUE_RETRY, TIMESTAMP_RETRY, InvalidOutput, fit_time_points, instruction, label, language, missing_lines, user_text checkpoint('preprocess') preparing = time.monotonic() processor = AutoProcessor.from_pretrained(folder, local_files_only=True, trust_remote_code=False) @@ -423,14 +423,18 @@ def on_token(timing): text = processor.decode(ids, skip_special_tokens=True) missing = missing_lines(text, value['text']) rewritten, rejected = None, None - references = [] + references, late, last = [], False, text try: rewritten = validate_rewrite(text, value, stats) except InvalidOutput as error: rejected = error if error.details['condition'] == 'missing_picture_reference': references = error.details['missing_references'] - if missing or references: + late = error.details['condition'] == 'timestamp_not_before_duration' + if late: + # Time points are checked first; ask for any media reference the draft also left out. + references = error.details['missing_references'] + if missing or references or late: # Both corrections share one turn and the existing context/cache allowance. correction = '' if references: @@ -438,6 +442,8 @@ def on_token(timing): correction = (f'Your rewrite omitted these required media references: {labels}. ' 'Preserve all existing content and add each missing reference where it belongs. ' 'Return the complete prompt in the same required section format.\n') + if late: + correction += TIMESTAMP_RETRY % ((value['seconds'],) * 3) if missing: correction += DIALOGUE_RETRY + '\n'.join('- [%s] %s' % (language(line), line) for line in missing) retry = messages + [{'role': 'assistant', 'content': text}, {'role': 'user', 'content': correction}] @@ -445,31 +451,49 @@ def on_token(timing): if retry_allowed(retry_inputs.input_ids.shape[-1], stats): again, _ = generate(torch, model, retry_inputs.to(device), 1800, time.monotonic(), lambda timing: None) second = processor.decode(again, skip_special_tokens=True) + # A cut-off correction is never used, not even to scale: fall back to the first draft. + last = second if len(again) < 1800 else text stats.update(retry_output_tokens=len(again), rewrite_seconds=time.monotonic() - loaded) if missing: stats['dialogue_retry'] = True + if late: + stats['timestamp_retry'] = True if references: stats['media_retry'] = True if any(each.startswith('= 1800: + if (references or late) and len(again) >= 1800: error = InvalidOutput('output_token_limit', second, value) error.details['retried'] = True stats['output_rejections'].append(error.details) raise error - if len(again) < 1800 and (references or len(missing_lines(second, value['text'])) < len(missing)): + if len(again) < 1800 and (references or late or len(missing_lines(second, value['text'])) < len(missing)): rewritten = candidate except InvalidOutput: - if references: + if references and not late: raise - elif references: - stats['media_retry_skipped'] = stats.get('dialogue_retry_skipped', 'context_length') - if any(each.startswith(' Date: Tue, 3 Nov 2026 02:31:22 +0000 Subject: [PATCH 23/26] Page check lists the music editor scripts; export the speech ramp on its own line --- web/editor_audio_model.js | 3 ++- web/health.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/editor_audio_model.js b/web/editor_audio_model.js index 5d8a52e2..dcc05460 100644 --- a/web/editor_audio_model.js +++ b/web/editor_audio_model.js @@ -232,7 +232,8 @@ export function applyTrackAudio(block, blockStart, track, tracks, {rate = 48000, } // Lower during speech: a clip with duck true sits DUCK_DB lower wherever another audible clip has speech, // with DUCK_RAMP_SECONDS ramps just outside each stretch. freevideo_engine/editor_audio.py has the same rules. -export const DUCK_DB = -8, DUCK_RAMP_SECONDS = .3; +export const DUCK_DB = -8; +export const DUCK_RAMP_SECONDS = .3; const changedSpeed = clip => (clip.speed ?? 1) !== 1 || !!clip.reverse; // spans: Map or object of 'kind:id' → [[start, end] seconds]; ranges are timeline samples [first, last). export function duckRanges(tracks, spans, rate = 48000, fps = 24) { diff --git a/web/health.js b/web/health.js index 3b1a292b..d04f2c34 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 = ['audio_original.js', 'chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.js', 'agent_confirm_settings.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'chat_attachments.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'chain_rules.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', - 'editor.js', 'editor_actions.js', 'editor_lifecycle.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_expand.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.js', 'editor_actions.js', 'editor_lifecycle.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_expand.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_duration.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_music.js', 'editor_music_model.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', 'failure_issue.js', 'feedback.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'image_lora.js', 'launch_visit.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'lora_check.js', 'library.js', 'motion.js', 'native_errors.js', 'translate_guard.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_sections.js', 'prompt_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'issue_button.js', 'studio_recent.js', 'result_actions.js', 'result_reuse.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', 'replace_person.js', 'replace_state.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', From bf64e0f8dddb619cfc06fe36487d460c00d14997 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 07:45:50 +0000 Subject: [PATCH 24/26] Release notes for 0.4.30 --- freevideo_engine/release_notes.json | 87 ++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 9e0a1a07..557c1591 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,74 @@ { "schema": 1, - "product_version": "0.4.29", + "product_version": "0.4.30", "en": { - "summary": "Music can be generated on the second audio track from the generation bar (not on Mac), scenes can be added to the character library, long videos can be extended by typing a length in seconds, and Autopilot chat continues a failed or stopped video from where it left off.", + "summary": "Image generation on Windows machines with little memory no longer stops while the model loads, large requests are steadier in the second pass, and the editor, the launcher and phone access now explain what stopped and what to do next.", "changes": [ - "Editor: music can be made from the generation bar by dragging a range on the second audio track, which new edits now show by default. Choose a style and a mood and optionally describe the music; if the music model is not installed yet, it is downloaded first. A selected generated piece can be regenerated in place, and one undo restores the previous piece. Generated music is lowered while someone speaks; this can be turned off with Lower during speech in the clip properties. Not available on Mac.", - "Editor: the timeline scrolls vertically when the second audio track, text or subtitle rows do not fit, with the ruler kept in place.", - "Character library: scenes can be added from your own pictures or from several AI candidates, and mentioned with @ in the prompt; the time of day, weather, light and tone written in the prompt are kept in the generated video.", - "Long videos: when an ungenerated draft part or a range on the picture track is selected, the generation bar shows a Duration field in whole seconds. A typed length follows the same rules as dragging the right edge; a value out of range is clamped with a short note, and one undo restores the previous length. When a video is extended, the last continuation part, including a single added part, can be as short as 2 s; parts in the middle still last at least 5 s.", - "Long videos: joining parts no longer fails at the last step when one part differs from the first in size or encoding; such a part is now matched to the first part before the parts are joined, and when that is not possible, the message names the part and the property that differs. A video that already has the most parts a continuous shot can have now says so as soon as it is chosen to be extended, and the limit is checked before generation starts.", - "Autopilot: Continue generating on a failed or stopped video picks up the same request and keeps the parts already made, also when the Agent is offline. A failed picture is submitted again with the original request. The latest message can be edited and sent again; unfinished tasks after it are stopped first.", - "Agent: when the Connect an agent card cannot get an address, it explains the reason, for example when the page was opened from another device or by machine name; the feedback report includes the status code and error code.", - "Prompts: when the Agent is asked to improve the prompt before generating, the generation uses the improved text exactly, and is no longer treated as a different prompt because quotation marks were changed while the text was copied. Asking for no background music in the scene text now works when the prompt still has the starter prompt's music section: the unchanged starter score is replaced with N/A, and a rewritten prompt ends with N/A. A music section written by the user is kept.", - "Speed: the background compile also prepares portrait videos and videos with a reference picture, so their first request no longer compiles GPU programs at the first step of each pass. The one-time background compile takes about 80 seconds longer; results are unchanged.", - "Install: a damaged offline package is now reported by name with a request to download it again, instead of a raw decompression error, and downloads over an unstable connection continue from the part already downloaded instead of stopping after the first round.", - "Install: on Windows, when another program, such as security software, holds FreeVideo's Python file open, setup now waits and creates the Python environment again instead of failing at the first attempt. When Windows refuses to run that file, the failure message says so in any system language and points to the security software.", - "Start-up: a downloaded source update is verified against the release signature again every time the launcher starts, not only when it is downloaded, and the diagnostic log times each start-up step of the video worker." + "Image generation: on Windows machines with little free memory, loading the image model no longer crashes. If loading is interrupted anyway, FreeVideo retries the request once automatically.", + "Generation: large requests on Windows are less likely to run out of memory in the second pass, and when they do, the retry is aimed at the memory that actually ran short instead of falling back to much slower settings. A generation also no longer stops when a memory copy is refused during sampling.", + "Creation panel: a note appears when other programs are using enough GPU memory or RAM to slow a generation down, and a generation that fails because of that shortage says so.", + "Prompt enhancement: a prompt over the 12,000-character limit now shows its current length, counting expanded @character descriptions, before anything is sent. When the prompt or references change while a rewrite is running, a plain note says so instead of a red error. Time points later than the end of the video no longer cause an error; they are fitted within the video's length.", + "Editor: playback stops with a notice at a clip that cannot be played, instead of running on over a black picture. After FreeVideo restarts, interrupted exports, multi-part generations and subtitle jobs no longer show as in progress, and can be started again or continued.", + "Phone access: when phone access cannot be opened, the page says why and offers \"Try again\", instead of showing \"Opening…\" indefinitely.", + "Start-up: the launcher's failure cards now explain the cause and the next step. An access-denied error is titled \"Access denied\" and shows where access was denied; a port in use offers \"Change address\"; a refused setup step, an expired diagnostic report and a failed report export also show the next step. When Python fails to install during setup, the card shows the installer's own error message instead of only an exit code. Exporting a report can replace an existing file.", + "Install: while a downloaded image model file is being verified, its card says \"Checking\". On Windows, entry folders left in the shared FV folder by removed installations are cleaned up. If the setup tool has gone missing before an install step, it is downloaded again instead of the step failing.", + "Multi-GPU machines: requests from the Creation panel and the Agent that include images, continue a clip or are longer now run on a free card alongside other generations. A single very large task runs on its own instead of waiting in the queue indefinitely.", + "GPU memory: very long second passes, such as ones with large reference videos, need less GPU memory on smaller cards. When other programs take GPU memory during a run, decoding adapts instead of failing and starting over. Results are unchanged." ] }, "zh": { - "summary": "可以在第二条声音轨上用生成栏生成配乐(暂不支持 Mac);角色库可以新建场景;长视频可以直接填写秒数来接着生成;AI 全自动对话中失败或停止的视频可以从中断处继续生成。", + "summary": "内存较小的 Windows 机器生成图片时,不再在加载模型时中断;大画面请求的第二遍更稳定;剪辑视图、启动器和手机访问出问题时,会说明原因和下一步。", "changes": [ - "剪辑视图:在第二条声音轨上拖出一段,即可用生成栏生成配乐;新剪辑默认显示这条声音轨。可选择风格和情绪,也可另写描述;配乐模型未安装时会先下载。选中已生成的配乐可以原位重新生成,一次撤销即可恢复原来那段。生成的配乐在有人说话处自动降低音量,可在片段属性中取消“对白时降低音量”。暂不支持 Mac。", - "剪辑视图:第二条声音轨、文字或字幕行放不下时,时间线可以纵向滚动,标尺保持固定。", - "角色库:可以用自己的图片或从 AI 生成的多张候选中新建场景,并在提示词中用 @ 引用;提示词里写的时间、天气、光线和色调会保留到生成的视频中。", - "长视频:选中未生成的草稿段或画面轨上的一段范围时,生成栏显示以整秒为单位的“时长”输入框。填写的时长与拖动右边缘规则相同;超出范围时自动取到边界并给出简短提示,一次撤销即可恢复原来的时长。接着生成时,最后一个接续段(包括只补一段的情况)最短可为 2 秒,中间的接续段仍至少 5 秒。", - "长视频:某一段的尺寸或编码与第 1 段不同时,不再在最后一步接合失败:接合前会先把这样的段转换为与第 1 段一致;无法转换时,提示会指明是哪一段、哪一项不同。已达到连续镜头最多段数的视频,选中接着生成时就会说明原因,生成前也会检查。", - "AI 全自动:视频失败或被停止后,点“继续生成”会按原请求接着做,保留已完成的段,Agent 不在线时同样可用。图片失败时按原请求重新提交。最后一条消息可以修改后重新发送,其后未完成的任务会先停止。", - "Agent:“连接 Agent”卡片取不到地址时,会说明原因(例如从其他设备或用机器名打开页面);反馈报告附带状态码和错误码。", - "提示词:让 Agent 先润色提示词再生成时,生成使用的就是润色后的原文,不会因抄写时引号被改动而被当作另一段提示词。在场景文字里要求不要背景音乐,而提示词仍带着默认示例的配乐段时,这一要求现在会生效:没改过的默认配乐会被换成 N/A,提示词重写的结果也以 N/A 结尾。用户自己写的配乐段保持不变。", - "速度:后台预编译也准备竖向视频和带参考图的视频,它们的第一次请求不再在每遍的第一步现场编译 GPU 程序。一次性的后台编译多约 80 秒,结果不变。", - "安装:离线包损坏时,现在会指出文件名并提示重新下载,不再显示解压缩的原始错误;网络不稳定时,下载会接着已下载的部分继续,不再在第一轮失败后直接停止。", - "安装:在 Windows 上,FreeVideo 的 Python 文件被其他程序(例如安全软件)占用时,安装程序会等待并重新创建 Python 环境,不再第一次失败就停止;Windows 拒绝运行这个文件时,失败提示会说明原因并指向安全软件,不受系统语言影响。", - "启动:已下载的源码更新在启动器每次启动时都会再按发布签名校验,不只在下载时校验;诊断日志也为视频 worker 的启动各步计时。" + "图像生成:可用内存较小的 Windows 机器加载图像模型时不再崩溃。加载仍意外中断时,FreeVideo 会自动重试一次。", + "生成:Windows 上的大画面请求在第二遍更不容易出现内存不足;出现时,重试会针对实际不足的那部分内存,不再退到慢得多的设置。采样中途内存拷贝被拒绝时,生成也不再中止。", + "创作面板:其他程序占用的显存或内存会使生成变慢时,面板会提示;因此类不足导致生成失败时,失败卡片同样说明这一点。", + "提示词增强:提示词超过 12,000 字符上限时,发送前会显示当前字数,展开的 @角色描述一并计入。改写过程中原文或素材有改动时,改为普通提示,不再显示红色错误。提示词里晚于视频结尾的时间点不再报错,会调整到视频时长以内。", + "剪辑视图:播放到无法播放的素材时会停下并提示,不再黑屏空走。FreeVideo 重启后,中断的导出、分段生成和字幕任务不再显示为进行中,可以重新开始或继续。", + "手机访问:手机连接无法打开时,网页会说明原因并提供“重试”,不再一直显示“正在打开…”。", + "启动:启动器的失败卡片会说明原因和下一步。拒绝访问类错误的标题为“访问被拒绝”,并写出被拒绝的位置;端口被占用时提供“更改地址”;安装步骤被拒绝、诊断报告过期和导出报告失败时,也会给出下一步。安装过程中 Python 安装失败时,失败卡片会显示安装器的报错,而不只是退出码。导出报告时可以覆盖已有文件。", + "安装:下载完成的图像模型文件在校验时,卡片显示“正在校验”。Windows 上,已删除的安装在共用 FV 目录里留下的入口目录会被清理。安装步骤开始前如果安装工具缺失,会重新下载,安装步骤不再因此失败。", + "多显卡机器:创作面板和 Agent 发出的带图、续接或较长的请求,现在可以在空闲的显卡上与其他生成同时运行。单个很大的任务改为单独运行,不再一直排队。", + "显存:较长的第二遍(例如参考视频很大的请求)在小显存显卡上占用的显存更少。运行中其他程序占用显存时,解码会自行调整,不再失败后重新开始。结果不变。" ] }, "history": [ + { + "product_version": "0.4.29", + "en": { + "summary": "Music can be generated on the second audio track from the generation bar (not on Mac), scenes can be added to the character library, long videos can be extended by typing a length in seconds, and Autopilot chat continues a failed or stopped video from where it left off.", + "changes": [ + "Editor: music can be made from the generation bar by dragging a range on the second audio track, which new edits now show by default. Choose a style and a mood and optionally describe the music; if the music model is not installed yet, it is downloaded first. A selected generated piece can be regenerated in place, and one undo restores the previous piece. Generated music is lowered while someone speaks; this can be turned off with Lower during speech in the clip properties. Not available on Mac.", + "Editor: the timeline scrolls vertically when the second audio track, text or subtitle rows do not fit, with the ruler kept in place.", + "Character library: scenes can be added from your own pictures or from several AI candidates, and mentioned with @ in the prompt; the time of day, weather, light and tone written in the prompt are kept in the generated video.", + "Long videos: when an ungenerated draft part or a range on the picture track is selected, the generation bar shows a Duration field in whole seconds. A typed length follows the same rules as dragging the right edge; a value out of range is clamped with a short note, and one undo restores the previous length. When a video is extended, the last continuation part, including a single added part, can be as short as 2 s; parts in the middle still last at least 5 s.", + "Long videos: joining parts no longer fails at the last step when one part differs from the first in size or encoding; such a part is now matched to the first part before the parts are joined, and when that is not possible, the message names the part and the property that differs. A video that already has the most parts a continuous shot can have now says so as soon as it is chosen to be extended, and the limit is checked before generation starts.", + "Autopilot: Continue generating on a failed or stopped video picks up the same request and keeps the parts already made, also when the Agent is offline. A failed picture is submitted again with the original request. The latest message can be edited and sent again; unfinished tasks after it are stopped first.", + "Agent: when the Connect an agent card cannot get an address, it explains the reason, for example when the page was opened from another device or by machine name; the feedback report includes the status code and error code.", + "Prompts: when the Agent is asked to improve the prompt before generating, the generation uses the improved text exactly, and is no longer treated as a different prompt because quotation marks were changed while the text was copied. Asking for no background music in the scene text now works when the prompt still has the starter prompt's music section: the unchanged starter score is replaced with N/A, and a rewritten prompt ends with N/A. A music section written by the user is kept.", + "Speed: the background compile also prepares portrait videos and videos with a reference picture, so their first request no longer compiles GPU programs at the first step of each pass. The one-time background compile takes about 80 seconds longer; results are unchanged.", + "Install: a damaged offline package is now reported by name with a request to download it again, instead of a raw decompression error, and downloads over an unstable connection continue from the part already downloaded instead of stopping after the first round.", + "Install: on Windows, when another program, such as security software, holds FreeVideo's Python file open, setup now waits and creates the Python environment again instead of failing at the first attempt. When Windows refuses to run that file, the failure message says so in any system language and points to the security software.", + "Start-up: a downloaded source update is verified against the release signature again every time the launcher starts, not only when it is downloaded, and the diagnostic log times each start-up step of the video worker." + ] + }, + "zh": { + "summary": "可以在第二条声音轨上用生成栏生成配乐(暂不支持 Mac);角色库可以新建场景;长视频可以直接填写秒数来接着生成;AI 全自动对话中失败或停止的视频可以从中断处继续生成。", + "changes": [ + "剪辑视图:在第二条声音轨上拖出一段,即可用生成栏生成配乐;新剪辑默认显示这条声音轨。可选择风格和情绪,也可另写描述;配乐模型未安装时会先下载。选中已生成的配乐可以原位重新生成,一次撤销即可恢复原来那段。生成的配乐在有人说话处自动降低音量,可在片段属性中取消“对白时降低音量”。暂不支持 Mac。", + "剪辑视图:第二条声音轨、文字或字幕行放不下时,时间线可以纵向滚动,标尺保持固定。", + "角色库:可以用自己的图片或从 AI 生成的多张候选中新建场景,并在提示词中用 @ 引用;提示词里写的时间、天气、光线和色调会保留到生成的视频中。", + "长视频:选中未生成的草稿段或画面轨上的一段范围时,生成栏显示以整秒为单位的“时长”输入框。填写的时长与拖动右边缘规则相同;超出范围时自动取到边界并给出简短提示,一次撤销即可恢复原来的时长。接着生成时,最后一个接续段(包括只补一段的情况)最短可为 2 秒,中间的接续段仍至少 5 秒。", + "长视频:某一段的尺寸或编码与第 1 段不同时,不再在最后一步接合失败:接合前会先把这样的段转换为与第 1 段一致;无法转换时,提示会指明是哪一段、哪一项不同。已达到连续镜头最多段数的视频,选中接着生成时就会说明原因,生成前也会检查。", + "AI 全自动:视频失败或被停止后,点“继续生成”会按原请求接着做,保留已完成的段,Agent 不在线时同样可用。图片失败时按原请求重新提交。最后一条消息可以修改后重新发送,其后未完成的任务会先停止。", + "Agent:“连接 Agent”卡片取不到地址时,会说明原因(例如从其他设备或用机器名打开页面);反馈报告附带状态码和错误码。", + "提示词:让 Agent 先润色提示词再生成时,生成使用的就是润色后的原文,不会因抄写时引号被改动而被当作另一段提示词。在场景文字里要求不要背景音乐,而提示词仍带着默认示例的配乐段时,这一要求现在会生效:没改过的默认配乐会被换成 N/A,提示词重写的结果也以 N/A 结尾。用户自己写的配乐段保持不变。", + "速度:后台预编译也准备竖向视频和带参考图的视频,它们的第一次请求不再在每遍的第一步现场编译 GPU 程序。一次性的后台编译多约 80 秒,结果不变。", + "安装:离线包损坏时,现在会指出文件名并提示重新下载,不再显示解压缩的原始错误;网络不稳定时,下载会接着已下载的部分继续,不再在第一轮失败后直接停止。", + "安装:在 Windows 上,FreeVideo 的 Python 文件被其他程序(例如安全软件)占用时,安装程序会等待并重新创建 Python 环境,不再第一次失败就停止;Windows 拒绝运行这个文件时,失败提示会说明原因并指向安全软件,不受系统语言影响。", + "启动:已下载的源码更新在启动器每次启动时都会再按发布签名校验,不只在下载时校验;诊断日志也为视频 worker 的启动各步计时。" + ] + } + }, { "product_version": "0.4.28", "en": { From ae835caf4eceec6786220a8d618687b88cc7fb1a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 07:49:56 +0000 Subject: [PATCH 25/26] Release notes for 0.4.30 name @ mentions without the handle form --- freevideo_engine/release_notes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 557c1591..e2cdf63e 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -7,7 +7,7 @@ "Image generation: on Windows machines with little free memory, loading the image model no longer crashes. If loading is interrupted anyway, FreeVideo retries the request once automatically.", "Generation: large requests on Windows are less likely to run out of memory in the second pass, and when they do, the retry is aimed at the memory that actually ran short instead of falling back to much slower settings. A generation also no longer stops when a memory copy is refused during sampling.", "Creation panel: a note appears when other programs are using enough GPU memory or RAM to slow a generation down, and a generation that fails because of that shortage says so.", - "Prompt enhancement: a prompt over the 12,000-character limit now shows its current length, counting expanded @character descriptions, before anything is sent. When the prompt or references change while a rewrite is running, a plain note says so instead of a red error. Time points later than the end of the video no longer cause an error; they are fitted within the video's length.", + "Prompt enhancement: a prompt over the 12,000-character limit now shows its current length, counting the descriptions of characters mentioned with @, before anything is sent. When the prompt or references change while a rewrite is running, a plain note says so instead of a red error. Time points later than the end of the video no longer cause an error; they are fitted within the video's length.", "Editor: playback stops with a notice at a clip that cannot be played, instead of running on over a black picture. After FreeVideo restarts, interrupted exports, multi-part generations and subtitle jobs no longer show as in progress, and can be started again or continued.", "Phone access: when phone access cannot be opened, the page says why and offers \"Try again\", instead of showing \"Opening…\" indefinitely.", "Start-up: the launcher's failure cards now explain the cause and the next step. An access-denied error is titled \"Access denied\" and shows where access was denied; a port in use offers \"Change address\"; a refused setup step, an expired diagnostic report and a failed report export also show the next step. When Python fails to install during setup, the card shows the installer's own error message instead of only an exit code. Exporting a report can replace an existing file.", From 885d971f5a487b02a06e3a997b8df786c543a1b1 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 3 Nov 2026 07:51:07 +0000 Subject: [PATCH 26/26] Release notes for 0.4.30 use plain ASCII dots in English --- freevideo_engine/release_notes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index e2cdf63e..705cfdcc 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -9,7 +9,7 @@ "Creation panel: a note appears when other programs are using enough GPU memory or RAM to slow a generation down, and a generation that fails because of that shortage says so.", "Prompt enhancement: a prompt over the 12,000-character limit now shows its current length, counting the descriptions of characters mentioned with @, before anything is sent. When the prompt or references change while a rewrite is running, a plain note says so instead of a red error. Time points later than the end of the video no longer cause an error; they are fitted within the video's length.", "Editor: playback stops with a notice at a clip that cannot be played, instead of running on over a black picture. After FreeVideo restarts, interrupted exports, multi-part generations and subtitle jobs no longer show as in progress, and can be started again or continued.", - "Phone access: when phone access cannot be opened, the page says why and offers \"Try again\", instead of showing \"Opening…\" indefinitely.", + "Phone access: when phone access cannot be opened, the page says why and offers \"Try again\", instead of showing \"Opening...\" indefinitely.", "Start-up: the launcher's failure cards now explain the cause and the next step. An access-denied error is titled \"Access denied\" and shows where access was denied; a port in use offers \"Change address\"; a refused setup step, an expired diagnostic report and a failed report export also show the next step. When Python fails to install during setup, the card shows the installer's own error message instead of only an exit code. Exporting a report can replace an existing file.", "Install: while a downloaded image model file is being verified, its card says \"Checking\". On Windows, entry folders left in the shared FV folder by removed installations are cleaned up. If the setup tool has gone missing before an install step, it is downloaded again instead of the step failing.", "Multi-GPU machines: requests from the Creation panel and the Agent that include images, continue a clip or are longer now run on a free card alongside other generations. A single very large task runs on its own instead of waiting in the queue indefinitely.",