From 5c09dabd7d4d22eb6d74d6ae53853601de9b57f0 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 20 Oct 2026 06:10:01 +0000 Subject: [PATCH 01/52] Unload idle ComfyUI models before a picture or the prompt model A video already unloads ComfyUI's own idle models before it starts. Pictures and the prompt model did not, and the picture budget did not count them, so a native workflow's model could keep a picture out of memory until ComfyUI was restarted. Both now send ComfyUI's own unload command first (only when a native model is loaded and no native prompt is running), keep the idle video engine during that unload, and the picture budget counts that memory. Picture failure reports record what was unloaded. --- freevideo_engine/comfy_residency.py | 60 +++++++++++++++++++++++++- freevideo_engine/image_gen/failure.py | 6 +++ freevideo_engine/image_gen/service.py | 22 +++++++++- freevideo_engine/prompt_vlm/service.py | 3 ++ 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/comfy_residency.py b/freevideo_engine/comfy_residency.py index b2ee8158..aa329952 100644 --- a/freevideo_engine/comfy_residency.py +++ b/freevideo_engine/comfy_residency.py @@ -2,10 +2,14 @@ from contextlib import contextmanager from functools import wraps import threading +import time from .resident_process import OWNER _local = threading.local() +# Set while release_native() waits: ComfyUI's prompt thread runs that unload, so the thread-local +# keep_engine_cache() cannot reach it. Our own unload frees native models only, as a video's does. +_releasing = threading.Event() @contextmanager @@ -18,6 +22,60 @@ def keep_engine_cache(): _local.keep = old +def _native(): + """ComfyUI's memory manager in this process, or None outside ComfyUI.""" + import sys + memory = sys.modules.get('comfy.model_management') + return memory if memory is not None and getattr(memory, 'current_loaded_models', None) is not None else None + + +def _prompt_running(server): + try: + return bool(server.prompt_queue.get_current_queue()[0]) + except (AttributeError, TypeError, IndexError): + return True + + +def native_reclaimable(server): + """GPU bytes that ComfyUI's own idle models hold in this process; release_native() returns them. + + 0 when nothing is loaded, or while a prompt is running (its models are in use).""" + memory = _native() + if memory is None or not memory.current_loaded_models or server is None or _prompt_running(server): + return 0 + try: + import torch + return int(torch.cuda.memory_reserved(memory.get_torch_device())) + except Exception: + return 0 + + +def release_native(server, *, timeout=10.): + """Before an image or prompt-model worker starts, unload ComfyUI's own idle models, as a video does. + + ComfyUI's prompt worker does it between prompts, through the same unload command its own + "free memory" button sends. Nothing happens when no native model is loaded, or while a prompt + is running (its models are in use). Returns what was released.""" + memory = _native() + if memory is None or not memory.current_loaded_models or server is None or _prompt_running(server): + return None + import torch + device = memory.get_torch_device() + before = dict(models=len(memory.current_loaded_models), reserved_bytes=int(torch.cuda.memory_reserved(device))) + _releasing.set() + try: + server.prompt_queue.set_flag('unload_models', True) + deadline = time.monotonic() + timeout + while memory.current_loaded_models and time.monotonic() < deadline: + time.sleep(.05) + finally: + _releasing.clear() + if not memory.current_loaded_models: + memory.soft_empty_cache() + return dict(before, released=not memory.current_loaded_models, + reserved_after_bytes=int(torch.cuda.memory_reserved(device))) + + def install(memory): if getattr(memory.free_memory, '_freevideo_resident_cooperation', False) is True: return @@ -28,7 +86,7 @@ def free_memory(memory_required, device, *args, **kwargs): # FreeVideo's own handoff releases native Comfy models only. Other # nodes, and Comfy's explicit unload command, can reclaim our idle # process before choosing a lower-memory native loading strategy. - if (not getattr(_local, 'keep', False) and not OWNER.active and + if (not getattr(_local, 'keep', False) and not _releasing.is_set() and not OWNER.active and OWNER.process is not None and OWNER.process.poll() is None): if device is None or memory_required > memory.get_free_memory(device): OWNER.release('comfy_model') diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index 9055e01b..6aeb6ef9 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -199,6 +199,8 @@ def __init__(self, action, value): self.plan = None self.pack = None self.start_memory = None + # ComfyUI's own models this request unloaded before its worker started (comfy_residency.release_native). + self.native_release = None self.update_request(value) def note_start(self): @@ -301,6 +303,10 @@ def service(self, video_busy, worker_restarts, error): service = dict(video_busy=bool(video_busy), worker_restarts=worker_restarts) if self.start_memory: service['memory_at_request_start'] = clean_sample(self.start_memory) + released = self.native_release + if isinstance(released, dict): + service['comfy_models_released'] = {key: released[key] for key in + ('models', 'reserved_bytes', 'reserved_after_bytes', 'released') if type(released.get(key)) in (int, bool)} # Numbers and fixed words only, taken by the service when it saw the worker end. reading = clean_reading(getattr(error, 'exit_reading', None)) if reading: diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index a7b9e681..034baecb 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -4,6 +4,7 @@ from collections import OrderedDict from copy import deepcopy import json +import logging import math import os from pathlib import Path @@ -36,6 +37,15 @@ 'invalid_media', 'invalid_points', 'too_many_images')) +def comfy_server(): + """ComfyUI's server in this process, or None (tests, the engine's own command line).""" + try: + from server import PromptServer + return PromptServer.instance + except (ImportError, AttributeError): + return None + + class WorkerIdleExit(RuntimeError): """The worker closed itself after its idle timeout before it answered anything.""" @@ -658,6 +668,9 @@ def memory_budget(self, machine): from ..background_compile import COMPILE from ..gpu_budget import open_bounded from ..resident_process import OWNER + from ..comfy_residency import native_reclaimable + # ComfyUI's own idle models in this process: work() unloads them before the worker starts. + native = native_reclaimable(comfy_server()) def read(): gpu = Gpu(machine.get('gpu_uuid')) @@ -681,7 +694,7 @@ def read(): # The worker still checks live capacity before inference. return total credit = sum(size for pid, size in rows.items() if pid in owned) - return min(total, total - used + credit) + return min(total, total - used + credit + native) finally: gpu.close() @@ -878,6 +891,13 @@ def work(self, root, machine): raise BlockingIOError('busy_video') resident_process.OWNER.stop_prewarm() IDLE.stop() + from ..comfy_residency import release_native + released = release_native(comfy_server()) + if released is not None: + logging.info('[FreeVideo] Unloaded ComfyUI models before the picture: %s', released) + with self.mutex: + if self.context is not None: + self.context.native_release = released with runtime_lock(Path(root) / 'engine.lock', inherit=False) as descriptor: try: if self.session_stop.is_set(): diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index b8f6bee0..c5cbec57 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -402,6 +402,9 @@ def work(self, root, machine, action, value): OWNER.stop_prewarm() from ..encoder_prewarm import IDLE IDLE.stop() + from ..comfy_residency import release_native + from ..image_gen.service import comfy_server + release_native(comfy_server()) with runtime_lock(Path(root) / 'engine.lock', inherit=False) as descriptor: self.check() if action == 'install': From fd0d907e2afd5cf3a99fd19f52980abc54fb7815 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 26 Oct 2026 19:18:07 +0000 Subject: [PATCH 02/52] Keep pictures and prompt rewrites going when the ComfyUI unload fails The early unload of idle ComfyUI models only frees memory ahead of time; the request's own memory check still decides. An exception there is now logged and the request continues. --- freevideo_engine/image_gen/service.py | 6 +++++- freevideo_engine/prompt_vlm/service.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 034baecb..3ff148ff 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -892,7 +892,11 @@ def work(self, root, machine): resident_process.OWNER.stop_prewarm() IDLE.stop() from ..comfy_residency import release_native - released = release_native(comfy_server()) + try: + released = release_native(comfy_server()) + except Exception: # only frees memory early; the picture's own budget still decides + logging.warning('[FreeVideo] Could not unload ComfyUI models before the picture', exc_info=True) + released = None if released is not None: logging.info('[FreeVideo] Unloaded ComfyUI models before the picture: %s', released) with self.mutex: diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index c5cbec57..423248fa 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -1,6 +1,7 @@ """Optional rewrite and ask jobs; prompts, questions and answers stay in memory, never in job reports.""" import atexit import json +import logging import os from pathlib import Path import queue @@ -404,7 +405,10 @@ def work(self, root, machine, action, value): IDLE.stop() from ..comfy_residency import release_native from ..image_gen.service import comfy_server - release_native(comfy_server()) + try: + release_native(comfy_server()) + except Exception: # only frees memory early; the load's own free-memory check still decides + logging.warning('[FreeVideo] Could not unload ComfyUI models before the prompt model', exc_info=True) with runtime_lock(Path(root) / 'engine.lock', inherit=False) as descriptor: self.check() if action == 'install': From 84044039f9c4cf28109fb10e4e2962e38e5f4e09 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 02:27:55 +0000 Subject: [PATCH 03/52] Keep the decoder's converted streamed layers in RAM between clips when memory allows --- freevideo_engine/decode.py | 3 +- freevideo_engine/offload.py | 8 +++ freevideo_engine/streamed_weights.py | 79 +++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/decode.py b/freevideo_engine/decode.py index 7ed5a3e5..3f2ca806 100644 --- a/freevideo_engine/decode.py +++ b/freevideo_engine/decode.py @@ -72,7 +72,8 @@ def phase(name): from .streamed_weights import SafetensorLayers weight_source = SafetensorLayers((Path(base)/'vae').glob('*.safetensors'), [f'decoder.transformer_blocks.{index}.' for index in range(count, layers)], - intermediate_dtype=torch.float32 if linear_compute_cache else None) + intermediate_dtype=torch.float32 if linear_compute_cache else None, + keep_converted=True) vae, compute_cache_metrics = load_video_decoder(base, resident_blocks=count, weight_source=weight_source, linear_fp16=linear_compute_cache) vae.eval().requires_grad_(False) diff --git a/freevideo_engine/offload.py b/freevideo_engine/offload.py index 085db769..335ab91b 100644 --- a/freevideo_engine/offload.py +++ b/freevideo_engine/offload.py @@ -905,6 +905,11 @@ def stats(self): 'host_view_hits': getattr(self.weight_source, 'host_view_hits', 0) - self.host_view_hits_start, 'host_view_hit_bytes': getattr(self.weight_source, 'host_view_hit_bytes', 0) - self.host_view_hit_bytes_start, 'host_view_released': getattr(self.weight_source, 'host_view_released', 0), + # Converted layers kept in RAM for the clips still to come. + 'converted_layer_count': len(getattr(self.weight_source, 'converted', None) or {}), + 'converted_bytes': getattr(self.weight_source, 'converted_bytes', 0), + 'converted_hits': getattr(self.weight_source, 'converted_hits', 0), + 'converted_refused': getattr(self.weight_source, 'converted_refused', 0), # Direct reads kept in the file cache between steps and those passing through. 'cache_kept_layer_count': len(getattr(self.weight_source, 'cache_kept', None) or {}), 'cache_kept_bytes': getattr(self.weight_source, 'cache_kept_bytes', 0), @@ -950,6 +955,9 @@ def close(self): self.cached.clear() self.cache_candidates.clear() self.slots.clear() + release = getattr(self.weight_source, 'release_converted', None) + if release is not None: + release() self.closed = True def __enter__(self): diff --git a/freevideo_engine/streamed_weights.py b/freevideo_engine/streamed_weights.py index af23e448..36e172f0 100644 --- a/freevideo_engine/streamed_weights.py +++ b/freevideo_engine/streamed_weights.py @@ -383,6 +383,43 @@ def _low_page_priority(enabled): swap(previous) +# Converted decoder layers are private copies and charge commit, but they stand +# in for the clean file pages the streamed reads would otherwise keep cycling +# through the standby list, so the floor left to the rest of the system is +# smaller than for views. Admission is re-checked at every layer. +CONVERTED_RESERVE = int(1.5 * 2**30) + + +def _cgroup_headroom(mount='/sys/fs/cgroup', membership='/proc/self/cgroup'): + """Room left under this process's cgroup v2 memory limit, or None without one. + + Private copies count against the limit while clean file pages do not, so + only anonymous, shared and kernel memory is taken as used. + """ + try: + root = Path(mount) + for line in Path(membership).read_text(encoding='utf-8').splitlines(): + if line.startswith('0::'): + root = root / line[3:].lstrip('/') + limit = (root / 'memory.max').read_text(encoding='utf-8').strip() + if limit == 'max': + return None + stat = dict(row.split() for row in (root / 'memory.stat').read_text(encoding='utf-8').splitlines()) + return int(limit) - sum(int(stat.get(name, 0)) for name in ('anon', 'shmem', 'kernel')) + except (OSError, ValueError): + return None + + +def converted_fits(size, reserve=CONVERTED_RESERVE): + """Whether one more converted layer may stay in RAM as a private copy.""" + from .system import system_memory + available = system_memory()['available_bytes'] + headroom = _cgroup_headroom() + if headroom is not None: + available = min(available, headroom) + return available - size >= reserve + + def host_view_fits(size, reserve=HOST_VIEW_RESERVE): """Whether one more layer may stay in RAM as a read-only file view. @@ -399,11 +436,20 @@ def host_view_fits(size, reserve=HOST_VIEW_RESERVE): class SafetensorLayers: def __init__(self, paths, prefixes, *, intermediate_dtype=None, direct_read=None, host_views=False, - host_view_reserve=HOST_VIEW_RESERVE): + host_view_reserve=HOST_VIEW_RESERVE, keep_converted=False): from .system import windows self.direct_read = windows() if direct_read is None else direct_read self.prefixes = tuple(prefixes) self.intermediate_dtype = intermediate_dtype + # Layers converted from their checkpoint dtype (the decoder's FP16 + # Linear values) kept for the layers' next uses. A tile decode streams + # the same layers once per clip; converting reads the whole FP32 layer + # each time, from disk once the file cache cannot hold them. + self.keep_converted = keep_converted and intermediate_dtype is not None + self.converted = {} + self.converted_bytes = 0 + self.converted_hits = 0 + self.converted_refused = 0 self.files = {} self.handle_files = {} self.keys = {} @@ -701,6 +747,25 @@ def placeholders(self, index, path): else torch.empty(shape, dtype=dtype)) return tensors + def _keep_converted(self, index, tensors, shared): + """Keep this layer's converted tensors if RAM has room, as private copies.""" + size = sum(value.numel() * value.element_size() for value in tensors.values()) + with self.cache_lock: + if index in self.converted: + return + if not converted_fits(size): + self.converted_refused += 1 + return + # A tensor that needed no conversion may still map the checkpoint. + self.converted[index] = {name: value.clone() if name in shared else value + for name, value in tensors.items()} + self.converted_bytes += size + + def release_converted(self): + with self.cache_lock: + self.converted.clear() + self.converted_bytes = 0 + def adopt(self, index): """Keep a layer that loading just read as views, so its first pass reads no disk. @@ -812,8 +877,16 @@ def read_ahead(self, layouts, pinned): @contextmanager def layer(self, index, layout): from contextlib import ExitStack + with self.cache_lock: + kept = self.converted.get(index) + if kept is not None: + self.converted_hits += 1 + if kept is not None: + yield dict(kept) + return prefix = self.prefixes[index] tensors = {} + shared = set() paths = {self.keys[prefix + name] for _, name, *_ in layout} for path in paths: self.check(path) @@ -831,6 +904,10 @@ def layer(self, index, layout): if self.intermediate_dtype is not None: tensor = tensor.to(dtype=self.intermediate_dtype) tensors[name] = tensor.to(dtype=dtype) + if tensors[name] is tensor: + shared.add(name) + if self.keep_converted: + self._keep_converted(index, tensors, shared) yield tensors finally: tensors.clear() From ae1214165d1bb856e29c0d7e0204ef400e19b511 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 21 Oct 2026 03:11:50 +0000 Subject: [PATCH 04/52] Eight attention heads for the Windows small first pass up to 12 s --- freevideo_engine/policy.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index d7ea6248..d11c80c0 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -98,6 +98,12 @@ # allocator retry and no sample over the local budget. WINDOWS_SMALL_HEAD8_TOKENS = 18144 + 4 * 1008 WINDOWS_SMALL_HEAD8_FRAMES = 243 +# The same interval for Ada and Blackwell reaches 12.25 s (294 frames): the +# 672x384x277 first pass of a 1344x768 11.5 s request measured head 4/8 at +# 22.3/15.5, 19.7/17.6 and 21.6/17.4 s per warm step on an RTX PRO 6000 held to +# an 8 GB laptop's 6.96 GB budget, 3.50 and 3.87 GiB reserved, no allocator +# retry. Ampere and the int8 paths keep 243. +WINDOWS_SMALL_HEAD8_LONG_FRAMES = 294 # Int8 without LoRA reaches full resolution at the same frame count. With the # Windows allocator (no expandable segments; its four-head workspace measured # 6.15 GiB against 6.18 natively on Windows at the same placement), @@ -1208,7 +1214,7 @@ def held(group): # by moving attention outputs to the host just to widen the group. windows_small_head8 = (desktop and not ampere and legs == ['sage2', 'sage2'] and effective_tokens is not None - and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES + and canvas['frames'] <= WINDOWS_SMALL_HEAD8_LONG_FRAMES and effective_tokens <= WINDOWS_SMALL_HEAD8_TOKENS) windows_int8_full = (desktop and not lora and legs == ['sage2', 'sage2'] and effective_tokens is not None From d6fc6e6d4779de540566ecbfd65136719fc9a164 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sun, 25 Oct 2026 01:49:31 +0000 Subject: [PATCH 05/52] Keep eight heads on the small first pass for requests without LoRA, and bound the decoder cache Keep the small-first-pass eight-head extension to requests without LoRA, bound the decoder cache by the request RAM budget. The Windows small first pass takes eight heads up to 12.25 s only when no LoRA is loaded; LoRA requests keep the 243-frame limit. Correct the comment on which paths keep 243. The decoder's converted-layer cache now also stays inside the request's own RAM budget and reads cgroup limits through the shared hardware helper, so it can neither trip an explicit budget nor raise the peak later plans treat as demonstrated. Narrow the recorded capability table tolerance to the two moved entries. Add tests for cache release, consumer errors, concurrent first use and re-admission. --- freevideo_engine/decode.py | 15 ++++++++++-- freevideo_engine/policy.py | 7 ++++-- freevideo_engine/streamed_weights.py | 34 +++++++++------------------- freevideo_engine/worker.py | 3 ++- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/freevideo_engine/decode.py b/freevideo_engine/decode.py index 3f2ca806..d3c94316 100644 --- a/freevideo_engine/decode.py +++ b/freevideo_engine/decode.py @@ -26,7 +26,7 @@ def decode_to_file(latents, audio_latents, out_path, *, base, offload=False, pre preload=False, pin_weights=False, artifacts_dir=None, phase_callback=None, stream_output=False, stream_weights=False, resident_blocks=0, model_cache=None, linear_compute_cache=False, continuation=None, audio_lead_in_callback=None, output_size=None, - hide_tail_frames=0): + hide_tail_frames=0, ram_budget_bytes=None): from .continuation import validate_hide_tail validate_hide_tail(hide_tail_frames) def phase(name): @@ -70,10 +70,21 @@ def phase(name): count = resident_blocks if offload else layers if stream_weights: from .streamed_weights import SafetensorLayers + room = None + if ram_budget_bytes is not None: + # Converted layers are private memory: keep them inside the request's own + # working-memory budget, so they can neither trip an explicit budget nor + # lift the peak that later plans take as demonstrated. + def room(): + import os + from .ram import ProcessMemory + from .system import memory_sample + used = memory_sample(ProcessMemory().sample(os.getpid())) + return ram_budget_bytes - used if type(used) is int and used >= 0 else None weight_source = SafetensorLayers((Path(base)/'vae').glob('*.safetensors'), [f'decoder.transformer_blocks.{index}.' for index in range(count, layers)], intermediate_dtype=torch.float32 if linear_compute_cache else None, - keep_converted=True) + keep_converted=True, converted_room=room) vae, compute_cache_metrics = load_video_decoder(base, resident_blocks=count, weight_source=weight_source, linear_fp16=linear_compute_cache) vae.eval().requires_grad_(False) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index d11c80c0..b1cb74e6 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -102,7 +102,9 @@ # 672x384x277 first pass of a 1344x768 11.5 s request measured head 4/8 at # 22.3/15.5, 19.7/17.6 and 21.6/17.4 s per warm step on an RTX PRO 6000 held to # an 8 GB laptop's 6.96 GB budget, 3.50 and 3.87 GiB reserved, no allocator -# retry. Ampere and the int8 paths keep 243. +# retry, all without LoRA. Requests with LoRA, Ampere FP8 and the two Windows +# int8 paths above the small canvas (windows_int8_full, windows_int8_staged_head8) +# keep 243. WINDOWS_SMALL_HEAD8_LONG_FRAMES = 294 # Int8 without LoRA reaches full resolution at the same frame count. With the # Windows allocator (no expandable segments; its four-head workspace measured @@ -1214,7 +1216,8 @@ def held(group): # by moving attention outputs to the host just to widen the group. windows_small_head8 = (desktop and not ampere and legs == ['sage2', 'sage2'] and effective_tokens is not None - and canvas['frames'] <= WINDOWS_SMALL_HEAD8_LONG_FRAMES + and (canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES + or (not lora and canvas['frames'] <= WINDOWS_SMALL_HEAD8_LONG_FRAMES)) and effective_tokens <= WINDOWS_SMALL_HEAD8_TOKENS) windows_int8_full = (desktop and not lora and legs == ['sage2', 'sage2'] and effective_tokens is not None diff --git a/freevideo_engine/streamed_weights.py b/freevideo_engine/streamed_weights.py index 36e172f0..2748e892 100644 --- a/freevideo_engine/streamed_weights.py +++ b/freevideo_engine/streamed_weights.py @@ -390,31 +390,17 @@ def _low_page_priority(enabled): CONVERTED_RESERVE = int(1.5 * 2**30) -def _cgroup_headroom(mount='/sys/fs/cgroup', membership='/proc/self/cgroup'): - """Room left under this process's cgroup v2 memory limit, or None without one. +def converted_fits(size, reserve=CONVERTED_RESERVE, room=None): + """Whether one more converted layer may stay in RAM as a private copy. - Private copies count against the limit while clean file pages do not, so - only anonymous, shared and kernel memory is taken as used. + ``room`` is what the request's own RAM budget still allows, when it has one. """ - try: - root = Path(mount) - for line in Path(membership).read_text(encoding='utf-8').splitlines(): - if line.startswith('0::'): - root = root / line[3:].lstrip('/') - limit = (root / 'memory.max').read_text(encoding='utf-8').strip() - if limit == 'max': - return None - stat = dict(row.split() for row in (root / 'memory.stat').read_text(encoding='utf-8').splitlines()) - return int(limit) - sum(int(stat.get(name, 0)) for name in ('anon', 'shmem', 'kernel')) - except (OSError, ValueError): - return None - - -def converted_fits(size, reserve=CONVERTED_RESERVE): - """Whether one more converted layer may stay in RAM as a private copy.""" + from .hardware import cgroup_memory from .system import system_memory + if room is not None and room < size: + return False available = system_memory()['available_bytes'] - headroom = _cgroup_headroom() + headroom = cgroup_memory()['reclaimable_available_bytes'] if headroom is not None: available = min(available, headroom) return available - size >= reserve @@ -436,7 +422,7 @@ def host_view_fits(size, reserve=HOST_VIEW_RESERVE): class SafetensorLayers: def __init__(self, paths, prefixes, *, intermediate_dtype=None, direct_read=None, host_views=False, - host_view_reserve=HOST_VIEW_RESERVE, keep_converted=False): + host_view_reserve=HOST_VIEW_RESERVE, keep_converted=False, converted_room=None): from .system import windows self.direct_read = windows() if direct_read is None else direct_read self.prefixes = tuple(prefixes) @@ -446,6 +432,7 @@ def __init__(self, paths, prefixes, *, intermediate_dtype=None, direct_read=None # the same layers once per clip; converting reads the whole FP32 layer # each time, from disk once the file cache cannot hold them. self.keep_converted = keep_converted and intermediate_dtype is not None + self.converted_room = converted_room self.converted = {} self.converted_bytes = 0 self.converted_hits = 0 @@ -753,7 +740,8 @@ def _keep_converted(self, index, tensors, shared): with self.cache_lock: if index in self.converted: return - if not converted_fits(size): + room = self.converted_room() if self.converted_room is not None else None + if (self.converted_room is not None and room is None) or not converted_fits(size, room=room): self.converted_refused += 1 return # A tensor that needed no conversion may still map the checkpoint. diff --git a/freevideo_engine/worker.py b/freevideo_engine/worker.py index 9b6950fe..ac812592 100644 --- a/freevideo_engine/worker.py +++ b/freevideo_engine/worker.py @@ -659,7 +659,8 @@ def keep_audio_lead_in(value): **({'continuation': continuation} if continuation is not None else {}), **({'audio_lead_in_callback': keep_audio_lead_in} if continuation is not None and continuation_data is not None else {}), - model_cache=resident, phase_callback=decode_phase, output_size=output_size, **decoder)) + model_cache=resident, phase_callback=decode_phase, output_size=output_size, + ram_budget_bytes=request.get('ram_budget_bytes'), **decoder)) if continuable and continuation_data is not None: if continuation is not None: # Every continuation of a film carries its first frame on, used or not: a later From c622ae1e247b9a69f6d0e00acbd60ef278815a48 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sun, 25 Oct 2026 23:48:33 +0000 Subject: [PATCH 06/52] Keep converted decoder layers across requests in the resident process A resident process now holds the converted decoder layers in a store that outlives one decode, so the next request reads no layer from disk. The store belongs to one checkpoint identity, and is released when host memory is short, when models are made room for, and on clear. --- freevideo_engine/decode.py | 3 +- freevideo_engine/resident_models.py | 8 ++++++ freevideo_engine/streamed_weights.py | 43 +++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/decode.py b/freevideo_engine/decode.py index d3c94316..50832b08 100644 --- a/freevideo_engine/decode.py +++ b/freevideo_engine/decode.py @@ -84,7 +84,8 @@ def room(): weight_source = SafetensorLayers((Path(base)/'vae').glob('*.safetensors'), [f'decoder.transformer_blocks.{index}.' for index in range(count, layers)], intermediate_dtype=torch.float32 if linear_compute_cache else None, - keep_converted=True, converted_room=room) + keep_converted=True, converted_room=room, + converted_store=getattr(model_cache, 'converted_store', None)) vae, compute_cache_metrics = load_video_decoder(base, resident_blocks=count, weight_source=weight_source, linear_fp16=linear_compute_cache) vae.eval().requires_grad_(False) diff --git a/freevideo_engine/resident_models.py b/freevideo_engine/resident_models.py index ac675357..185f01cc 100644 --- a/freevideo_engine/resident_models.py +++ b/freevideo_engine/resident_models.py @@ -152,6 +152,9 @@ def __init__(self): self.gpu_reserve = 0 self.ram_budget = None self.decisions = [] + from .streamed_weights import ConvertedStore + # Converted decoder layers streamed from the checkpoint; rebuilt on demand. + self.converted_store = ConvertedStore() # Set by a request that can read the whole device: bytes no process # holds, and the growth reserve that request keeps out of them. The # planning budget already excludes that reserve; the device reading @@ -241,6 +244,7 @@ def declined(why): return True def clear(self, reason='Request failed or session configuration changed'): + self.converted_store.clear() for role in list(self.entries): self.drop(role, reason) gc.collect() @@ -271,6 +275,8 @@ def settle(self, pinned=0): a retained copy left no room to pin and sampling took 95 s instead of 38 s. Otherwise the engine goes as it did with one worker per request. """ + if not self.ram_fits(8*GiB + int(pinned)): + self.converted_store.clear() for role in ('engine', 'encoder'): if role in self.entries and not self.ram_fits(8*GiB + int(pinned)): self.drop(role, 'Retained weights leave no host room for the next request') @@ -331,6 +337,8 @@ def available(): room = min(room, max(0, ceiling - torch.cuda.memory_allocated() - reserve)) return room before = available() + if not self.ram_fits(ram_need): + self.converted_store.clear() # Once conditioning is ready the current video cannot use the encoder # again. Reclaim its speculative weights before a VAE needed later in # this very request, regardless of how recently preloading touched it. diff --git a/freevideo_engine/streamed_weights.py b/freevideo_engine/streamed_weights.py index 2748e892..92437a74 100644 --- a/freevideo_engine/streamed_weights.py +++ b/freevideo_engine/streamed_weights.py @@ -420,10 +420,41 @@ def host_view_fits(size, reserve=HOST_VIEW_RESERVE): return available - size >= reserve +class ConvertedStore: + """Converted decoder layers that outlive one decode, for a resident process. + + Entries belong to one checkpoint identity; a different one empties the store. + """ + + def __init__(self): + self.identity = None + self.layers = {} + self.lock = threading.Lock() + + def bind(self, identity): + with self.lock: + if identity != self.identity: + self.layers = {} + self.identity = identity + return self.layers + + def size(self): + with self.lock: + return sum(value.numel() * value.element_size() + for tensors in list(self.layers.values()) for value in tensors.values()) + + def clear(self): + with self.lock: + self.layers.clear() + self.identity = None + + class SafetensorLayers: def __init__(self, paths, prefixes, *, intermediate_dtype=None, direct_read=None, host_views=False, - host_view_reserve=HOST_VIEW_RESERVE, keep_converted=False, converted_room=None): + host_view_reserve=HOST_VIEW_RESERVE, keep_converted=False, converted_room=None, + converted_store=None): from .system import windows + self.converted_store = None self.direct_read = windows() if direct_read is None else direct_read self.prefixes = tuple(prefixes) self.intermediate_dtype = intermediate_dtype @@ -494,6 +525,14 @@ def __init__(self, paths, prefixes, *, intermediate_dtype=None, direct_read=None self.byte_ranges[key] = (8 + length + start, end - start, spec['dtype'], tuple(spec['shape'])) self.check(path) + if converted_store is not None and self.keep_converted: + # A resident process keeps the layers for its next request; they are + # served only while the checkpoint files, layers and dtype are unchanged. + self.converted = converted_store.bind( + (tuple(sorted((str(path), identity) for path, identity in self.files.items())), + self.prefixes, str(self.intermediate_dtype))) + self.converted_bytes = converted_store.size() + self.converted_store = converted_store @staticmethod def identity(path): @@ -750,6 +789,8 @@ def _keep_converted(self, index, tensors, shared): self.converted_bytes += size def release_converted(self): + if self.converted_store is not None: + return # owned by the resident process, which releases it under memory pressure with self.cache_lock: self.converted.clear() self.converted_bytes = 0 From 9e426633be2cf76ce6866d5e1bd0b02169ce340a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 26 Oct 2026 19:26:12 +0000 Subject: [PATCH 07/52] Continued parts may be five seconds, with fewer parts per video A part that continues a video is now at least 5 s instead of 10 s. The planner still prefers continuing parts of 10 s or longer and falls back to parts of 5 s or longer only when the length or this device's single-run limit leaves no other way, so lengths such as 15-19 s and 29 s are no longer refused as unsplittable and devices that cannot make 10 s at once can continue videos. A video with a continuing part shorter than 10 s has at most 4 parts; with the first-frame reference, or when every continuing part is 10 s or longer, it keeps 8. The planner, chain store, runner, ComfyUI chain node, Agent and editor all read these numbers from one shared file (web/chain_rules.js), and the runner and Agent refuse a video with too many short parts with a matching error code. Editor: - A new continuation starts at 5 s on Mac and on devices that cannot make 10 s at once, and at 10 s otherwise; dragging a video's end continues it by at least 5 s. - Requests the backend would refuse (too many parts, too many short parts, parts too short) are blocked before they are sent, with the same message. - The model gains a setter for a continuation's length that moves later planned parts with it and checks the whole video against the limits. The tutorial, how-to page, Agent guide and Agent documentation describe the new limits. --- ...77\347\224\250\350\257\264\346\230\216.md" | 2 +- freevideo_engine/agent/drama.py | 8 +- freevideo_engine/agent/editor_runtime.py | 2 +- freevideo_engine/agent/errors.py | 3 + freevideo_engine/agent/guide.py | 17 +-- freevideo_engine/agent/jobs.py | 46 +++++--- freevideo_engine/agent/mcp.py | 2 +- freevideo_engine/agent/productions.py | 2 +- freevideo_engine/chain_plan.py | 109 +++++++++++++----- freevideo_engine/chain_runner.py | 90 ++++++++++----- freevideo_engine/chain_store.py | 4 +- freevideo_engine/comfy_chain.py | 15 ++- web/chain_rules.js | 1 + web/editor.js | 30 +++-- web/editor_genbar.js | 2 +- web/editor_model.js | 108 +++++++++++++++-- web/editor_store.js | 14 ++- web/editor_tracks.js | 7 +- web/health.js | 2 +- web/howto.js | 3 +- web/length_plan.js | 2 +- web/tutorial_book_content.js | 8 +- 22 files changed, 350 insertions(+), 127 deletions(-) create mode 100644 web/chain_rules.js diff --git "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" index 03fb88e0..dcc7dfd2 100644 --- "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -46,7 +46,7 @@ ### 限制 -- 两种做法一样,一条视频最多 8 段(第一段加 7 次接着生成);创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 110 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 +- 两种做法一样,一条视频最多 8 段(第一段加 7 次接着生成),有短于 10 秒的段时最多 4 段;创作面板里时长最多填 60 秒。接续的每一段至少 5 秒,每段都是 10 秒以上时整条约 110 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 - 接着生成使用轻量档,需要 NVIDIA 显卡或 Mac,也不能和“继续二采”同时使用。 - 单次生成的最长时长随画幅和显卡而不同,在创作面板的“可生成范围”中可查看各尺寸的最长时长和预计用时。 - 分段生成同样需要 NVIDIA 显卡或 Mac。 diff --git a/freevideo_engine/agent/drama.py b/freevideo_engine/agent/drama.py index a34f83ed..ac0b32b9 100644 --- a/freevideo_engine/agent/drama.py +++ b/freevideo_engine/agent/drama.py @@ -20,7 +20,7 @@ import time from . import spec as specs -from ..chain_plan import MIN_PART_SECONDS +from ..chain_plan import CONTINUE_SECONDS EXAMPLE = { 'title': '末班车', 'name': 'the-last-train', 'aspect': '9:16', 'quality': 'light', @@ -451,9 +451,9 @@ def intent(play, shot): def shot_seconds(play, shot): - """A shot says its length; else the film does; else 6 s, and 10 s for a shot that continues the one before - (a part that continues another video is at least 10 s).""" - return shot.get('seconds', play.get('seconds', MIN_PART_SECONDS if shot.get('continue') else 6)) + """A shot says its length; else the film does; else 6 s, and CONTINUE_SECONDS for a shot that continues the + one before (a full continuing part, which keeps the video to the full number of parts).""" + return shot.get('seconds', play.get('seconds', CONTINUE_SECONDS if shot.get('continue') else 6)) def shot_job(play, shot, index, ref): diff --git a/freevideo_engine/agent/editor_runtime.py b/freevideo_engine/agent/editor_runtime.py index b8bbf755..456783fb 100644 --- a/freevideo_engine/agent/editor_runtime.py +++ b/freevideo_engine/agent/editor_runtime.py @@ -16,7 +16,7 @@ # Definition order only; evaluation starts at editor_actions like the page's # import does. editor_model and editor_text_model import each other: the # module still loading is reached only through a namespace import. -MODULES = ('editor_picture_fields', 'editor_transport_model', 'editor_audio_model', 'editor_speed_model', +MODULES = ('chain_rules', 'editor_picture_fields', 'editor_transport_model', 'editor_audio_model', 'editor_speed_model', 'editor_text_model', 'editor_model', 'editor_room', 'editor_actions', 'editor_generation', 'sampling_effort', 'chain_support', 'editor_store') TIME_LIMIT = 5.0 diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index dd4dccc4..32303046 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -67,6 +67,7 @@ 'chain_missing': (409, True, 'The saved chain receipt is unavailable.', 'Restore the chain receipt and retry resume, or submit a new job.'), 'join_failed': (409, True, 'The generated blocks could not be joined; completed blocks are retained.', 'POST /freevideo/agent/v1/jobs/{id}/resume to retry joining the completed blocks.'), 'chain_too_long': (422, False, 'One continuous shot can have up to 8 parts. Make it shorter.', 'Submit a new job with a smaller seconds, or cut to a new shot with a new job without continue_from; continue_from counts the parts of the video it continues.'), + 'chain_short_parts': (422, False, 'With a part shorter than 10 seconds, this video can have up to 4 parts. Make each part at least 10 seconds long, or use fewer parts.', 'Submit a new job whose continuing parts are at least 10 seconds, with fewer parts, or with first_frame_reference true; continue_from counts the parts of the video it continues.'), 'chain_rejected': (409, False, 'The continuous backend rejected this generation.', 'Read GET /freevideo/agent/v1/capabilities and submit a supported generation request.'), 'invalid_wait': (422, False, 'wait must be a finite nonnegative number of seconds.', 'Use wait=0 or wait=25; larger valid values are clamped to 25 seconds.'), 'invalid_resume': (422, False, 'resume accepts an empty object or an optional ref only.', 'Send {} or {"ref":"resume-1"}; submit a new job to change generation parameters.'), @@ -302,6 +303,7 @@ def safe_details(value): 'invalid_dimensions': '图片尺寸超出了支持的范围', 'too_many_images': '参考图片数量超过了支持的范围', 'chain_too_long': '接续后的视频超过了支持的段数', + 'chain_short_parts': '有短于 10 秒的段时,接续后的视频超过了支持的段数', 'chain_rejected': '生成服务无法使用这组参数', 'generation_failed': '生成没有完成', 'internal_error': '没有完成', @@ -350,6 +352,7 @@ def safe_details(value): 'invalid_dimensions': 'The picture size is outside the supported range', 'too_many_images': 'There are more reference pictures than supported', 'chain_too_long': 'The continued video has more parts than supported', + 'chain_short_parts': 'With a part shorter than 10 seconds, the continued video has more parts than supported', 'chain_rejected': 'The generation service cannot use these settings', 'generation_failed': 'Generation did not finish', 'internal_error': 'Did not finish', diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 2a4268da..a61ea514 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -99,7 +99,7 @@ 'timing': [ 'Single-block frames are 24 fps on a 17n+5 grid (6 s -> 6.583 s, 10 s -> 10.125 s). ' 'Under 30 s defaults to one generation when the device supports this size and quality; unknown limits use 15 s. ' - 'Otherwise it uses as few parts as possible, each at least 10 s; how many depends on how long this computer can generate in one run, and the parts continue and join automatically. ' + 'Otherwise it uses as few parts as possible, each continuing part 10 s or longer where the length allows and never under 5 s; how many depends on how long this computer can generate in one run, and the parts continue and join automatically. ' 'Multiple blocks or continue_from need two-pass text generation: quality "light" or "medium" ' 'or sampling.two_pass=true and no preview. Their only media is media.references with at most 3 pictures, ' 'which every continuing part also sees (keeps the same people); first_frame_reference then gives way to them. ' @@ -250,9 +250,10 @@ 'Cut by default. Wherever the film can change shot, make the new shot from the reference pictures it should ' 'have (cast cards, scene pictures) instead of continuing the previous video. Continue a video only for a ' 'long take that cannot be cut, an action that must stay unbroken, or sound that must stay unbroken, and ' - 'continuing again and again is not recommended. One video has at most 8 parts (the first and 7 ' - 'continuations), each continued part at least 10 seconds, about 110 seconds in all; filling that is not ' - 'recommended. Past it, cut to a new shot.', + 'continuing again and again is not recommended. Each continued part is at least 5 seconds. One video has at ' + 'most 8 parts (the first and 7 continuations, about 110 seconds in all) when every continued part is at ' + 'least 10 seconds; with a continued part shorter than 10 seconds it has at most 4 parts, unless ' + 'first_frame_reference is true. Filling that is not recommended. Past it, cut to a new shot.', 'A question is not a request. When the user asks about progress or status ("is it working?", "what happened?") ' 'answer it in one sentence from what you know or from the job and white-model status, and start nothing; never ' 'use a question as a prompt. When a row carries "whitebox", the user was starting a white-model scene that is ' @@ -287,7 +288,8 @@ '先用 people(角色图和各自的位置)做出首帧,再用它生成视频。', '默认切镜头。能切镜头的地方就切:新镜头直接用对应的参考图(人物卡、场景图)生成,不接上一段视频。' '只有不能切镜的长镜头、必须连贯的动作、必须连贯的声音,才接着上一段生成,而且不建议一段接一段地连续接。' - '一条视频最多 8 段(第一段加 7 次接着生成),接着生成的每一段至少 10 秒,总长约 110 秒;不建议接满,超过就切镜头。', + '接着生成的每一段至少 5 秒。每段都不短于 10 秒时,一条视频最多 8 段(第一段加 7 次接着生成,总长约 110 秒);' + '有短于 10 秒的段时最多 4 段,first_frame_reference 为 true 时除外。不建议接满,超过就切镜头。', '问话不是要求。用户问进度或状态(“在做吗”“怎么了”)时,用一句话照实回答,依据是你所知道的、任务和白模的状态,' '什么都不要开始;不要把问句当提示词。某条 inbox 带有 whitebox 时,说明用户刚启动了白模场景,' '它还在准备或已经失败:照实说是哪一种,再等用户回话。', @@ -573,8 +575,9 @@ def connection_text(value): 'before uploading. Unknown fields are rejected and every batch is validated before it is recorded.', 'POST /plan gives the length supported for the current size and quality. Without continuation, only a ' 'single block can run; unknown device limits use 15 s. Multiple blocks return continuation_unavailable.', - 'One video has at most 8 parts (60 s in one request). continue_from counts the parts of the video it ' - 'continues; plan and submit refuse a longer one and name the longest seconds that still fits.', + 'One video has at most 8 parts (60 s in one request), or 4 when a continued part is shorter than 10 s and ' + 'first_frame_reference is not true. continue_from counts the parts of the video it continues; plan and ' + 'submit refuse a longer one and name the longest seconds that still fits.', 'features in the status reply names what this installation offers; false means not usable now (for example ' 'chain.auto while continuation is false), and a missing name means not offered here.', ] diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 73da1fd6..80bd5db2 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -187,7 +187,7 @@ def part_prompt_problems(spec, prefix=''): def _continued_part_problem(seconds, chain): - """True when a video that continues another has a part the backend refuses, or is under 10 s. + """True when a video that continues another has a part the backend refuses, or is under MIN_PART_SECONDS. The backend compares each part's node input frames (overlap included); a part that continues is also at least MIN_PART_SECONDS long. @@ -232,8 +232,8 @@ def parts_problems(spec, runner, prefix='', *, editor=None, work=None, planned=N """ if spec['kind'] != 'generate' or spec.get('refine_from'): return [] - from ..chain_plan import MIN_PART_SECONDS, PlanError, plan - from ..chain_runner import ChainTooLong + from ..chain_plan import MIN_PART_SECONDS, PlanError, parts_limit, plan + from ..chain_runner import ChainShortPartsTooMany, ChainTooLong from ..chain_store import reference continued, frames = bool(spec.get('continue_from')), single_frame_limit(spec) if editor is None: @@ -246,40 +246,50 @@ def parts_problems(spec, runner, prefix='', *, editor=None, work=None, planned=N return [length_problem(spec['seconds'], continued, prefix, minimum=MIN_PART_SECONDS, frames=frames)] if runner is None: return [] - limit = runner.max_parts - count = lambda seconds: len(plan(seconds, max_frames=frames, continued=continued)['blocks']) + # A video with a continuing part shorter than full_part_frames has fewer parts, unless every part sees the first frame. + limit, full = runner.max_parts, runner.full_part_frames + short_limit = parts_limit(True, spec.get('first_frame_reference') is True, limit) + + def short_blocks(blocks): + return any(block['frames'] < full for index, block in enumerate(blocks) if index or continued) try: with runner.lock: if editor is not None: - rows = [dict(id=b['id'], after=reference(b.get('after')), before=reference(b.get('before'))) - for b in editor['blocks']] - earlier, needed = 0, max(runner._depths(dict(id=None, blocks=rows)).values()) + rows = [dict(id=b['id'], after=reference(b.get('after')), before=reference(b.get('before')), + inputs=dict(frames=b['frames'])) for b in editor['blocks']] + lineage = runner._lineage(dict(id=None, blocks=rows)).values() + earlier, needed = 0, max(parts for parts, _ in lineage) + short = max((parts for parts, flag in lineage if flag), default=0) else: # A source the backend has no record of counts as one part, as it does there. - earlier = (runner._work_parts(work, ()) if work else planned or 1) if continued else 0 + earlier, made_short = ((runner._work_lineage(work, ()) if work else (planned or 1, False)) + if continued else (0, False)) needed = earlier + len(chain['blocks']) + short = needed if made_short or short_blocks(chain['blocks']) else 0 except (ValueError, KeyError, TypeError): return [] # Not a chain the backend accepts; it says why when the chain is created. - if needed <= limit: + if needed <= limit and short <= short_limit: return [] + error = ChainTooLong(needed, limit) if needed > limit else ChainShortPartsTooMany(short, short_limit) if editor is not None: - problem, got = schema.diagnostic(prefix + '/target', 'a range that takes at most %d parts' % limit, None), '%d parts' % needed + problem, got = schema.diagnostic(prefix + '/target', 'a range that takes at most %d parts' % error.limit, None), '%d parts' % error.parts else: # A length the planner refuses does not fit; a part that continues a video is at least MIN_PART_SECONDS. - def parts(seconds): + def fits(seconds): try: - return count(seconds) + blocks = plan(seconds, max_frames=frames, continued=continued)['blocks'] except ValueError: - return limit + 1 + return False + return earlier + len(blocks) <= (short_limit if made_short or short_blocks(blocks) else limit) shortest = MIN_PART_SECONDS if continued else 2 - fit = next((s for s in range(int(spec['seconds']), shortest - 1, -1) if earlier + parts(s) <= limit), None) + fit = next((s for s in range(int(spec['seconds']), shortest - 1, -1) if fits(s)), None) if fit is None: - problem = schema.diagnostic(prefix + '/continue_from', 'a video of fewer than %d parts' % limit, None) + problem = schema.diagnostic(prefix + '/continue_from', 'a video of fewer than %d parts' % error.limit, None) got = 'a video of %d parts' % earlier else: problem, got = schema.diagnostic(prefix + '/seconds', 'at most %d' % fit, spec['seconds']), None message = problem['message'] if got is None else problem['message'].replace('received None', 'received ' + got) - return [dict(problem, got=got or problem['got'], message='%s %s' % (ChainTooLong(needed, limit), message))] + return [dict(problem, got=got or problem['got'], message='%s %s' % (error, message))] def continuation_unavailable(): @@ -1635,7 +1645,7 @@ def start_chain(self, row, *, payload=None, resume=False): except ChainTooLong as error: # Submit and plan refuse this up front; a source that was still being made can only be counted here. row['resume_pending'] = False - self.finish(row, 'failed', error=errors.build('chain_too_long', validation=True, message=str(error))) + self.finish(row, 'failed', error=errors.build(error.code, validation=True, message=str(error))) return except (PlanError, ChainPartTooShort) as error: row['resume_pending'] = False diff --git a/freevideo_engine/agent/mcp.py b/freevideo_engine/agent/mcp.py index ff1f1815..ee5836aa 100644 --- a/freevideo_engine/agent/mcp.py +++ b/freevideo_engine/agent/mcp.py @@ -49,7 +49,7 @@ def tool_list(): dict(name='freevideo_prompt_guide', title='H3 prompt guide', description='How to write prompts: sections, shots and cuts, camera motion, dialogue, references, short-drama tips, and a full example.', annotations=read), dict(name='freevideo_schema', title='Job JSON Schemas', description='JSON Schemas of every job kind and of image edit recipes.', annotations=read), dict(name='freevideo_plan', title='Plan a job', description="Validate a job without running it: actual frames/seconds, sampling passes, warnings and a time estimate from this computer's history. Same fields as freevideo_generate.", annotations=read), - dict(name='freevideo_generate', title='Generate a video', description='Generate one video from an H3 prompt, up to 60 seconds. Under 30 s defaults to one generation if the device supports this size and quality; unknown limits use 15 s. Otherwise the video is cut into as few parts as possible, each at least 10 s, and how many parts depends on how long this computer can generate in one run; the parts continue and join automatically. Prefer a new shot with its reference pictures wherever the film can cut; continue only a take, action or sound that must stay unbroken. Use this for any single video, also after freevideo_enhance_prompt; the drama tools are only for a film of several separately chosen shots. Optionally from first/last frames or references (files, local paths, or frames of earlier jobs). Returns the job; with wait, its result (video path and URL).', annotations=write), + dict(name='freevideo_generate', title='Generate a video', description='Generate one video from an H3 prompt, up to 60 seconds. Under 30 s defaults to one generation if the device supports this size and quality; unknown limits use 15 s. Otherwise the video is cut into as few parts as possible, each continuing part 10 s or longer where the length allows and never under 5 s, and how many parts depends on how long this computer can generate in one run; the parts continue and join automatically. Prefer a new shot with its reference pictures wherever the film can cut; continue only a take, action or sound that must stay unbroken. Use this for any single video, also after freevideo_enhance_prompt; the drama tools are only for a film of several separately chosen shots. Optionally from first/last frames or references (files, local paths, or frames of earlier jobs). Returns the job; with wait, its result (video path and URL).', annotations=write), dict(name='freevideo_generate_image', title='Generate a picture', description='Generate a picture from a prompt; with images, change or combine input pictures; with people, make a first frame with them near the places you say, in the scene from scene_image or the prompt. The result is an input file to use as a first frame, as a reference, or in images again.', annotations=write), dict(name='freevideo_submit_batch', title='Submit a storyboard', description='Submit several jobs at once. Each row is a job object with "kind" (generate default, assemble, vlm, enhance, image) and an optional "ref"; later rows use "@ref" wherever a job id is expected (from_job, after, clips, refine_from). FreeVideo runs them in dependency order.', annotations=write), dict(name='freevideo_job', title='Job status', description='Status, progress, result or error of a job.', annotations=read), diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 6ee2c115..68345481 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -347,7 +347,7 @@ def _material(self, state, *, local=True): continue if continued or shot['continues']: # The same length rule as at submission, with this computer's single-run limit rather than the - # 15 s default: a shot that cannot be cut into parts of at least 10 s is refused here instead of + # 15 s default: a shot that cannot be cut into parts of at least 5 s is refused here instead of # starting a film in which that shot never gets a job. A shot not yet submitted carries no parent. try: pinned = spec.normalize(dict(item, continue_from=dict(from_job=placeholder)), port=self.jobs.port) diff --git a/freevideo_engine/chain_plan.py b/freevideo_engine/chain_plan.py index 5eb36f56..13289ecf 100644 --- a/freevideo_engine/chain_plan.py +++ b/freevideo_engine/chain_plan.py @@ -5,20 +5,47 @@ cannot do at once, are cut into blocks that continue one another. A block length given explicitly also cuts a video under 30 s. """ +import json import math +from pathlib import Path from .geometry import geometry FPS, UNIT, OVERLAP = 24, 17, 22 SINGLE_SECONDS, BLOCK_SECONDS, SHORTEST_BLOCK = 30, 15, 5 -# A part that continues another is at least this long (the first part is not held to it). -MIN_PART_SECONDS = 10 -MIN_PART_FRAMES = geometry(seconds=MIN_PART_SECONDS)['frames'] +# The limits on continuing parts, shared with the page (web/chain_rules.js: one JSON object) so that both apply +# the same numbers. +RULES = json.loads((Path(__file__).resolve().parents[1] / 'web' / 'chain_rules.js').read_text(encoding='utf-8') + .partition('=')[2].strip().removesuffix(';')) +# A part that continues another is at least MIN_PART_SECONDS (the first part is not held to it). A video with a +# continuing part shorter than FULL_PART_SECONDS has at most SHORT_MAX_PARTS parts, or MAX_PARTS with the +# first-frame reference; every other video has at most MAX_PARTS. +MIN_PART_SECONDS, FULL_PART_SECONDS = RULES['min_part_seconds'], RULES['full_part_seconds'] +MAX_PARTS, SHORT_MAX_PARTS = RULES['max_parts'], RULES['short_max_parts'] +# The length a new continuation starts with, and its length on devices that cannot continue with a full part. +CONTINUE_SECONDS, SHORT_CONTINUE_SECONDS = RULES['continue_seconds'], RULES['short_continue_seconds'] + + +def part_frames(seconds): + """The fewest frames a continuing block of `seconds` whole seconds asks for. Each block takes the shortest step + that reaches its whole second, so a block after another may ask for up to UNIT - 1 frames fewer than the same + length on its own (5 s alone is 158 frames, after another part 141).""" + return OVERLAP + UNIT * math.ceil((FPS * seconds - (UNIT - 1)) / UNIT) + + +# Continuing blocks below MIN_PART_FRAMES are too short; below FULL_PART_FRAMES they are short parts. +MIN_PART_FRAMES, FULL_PART_FRAMES = part_frames(MIN_PART_SECONDS), part_frames(FULL_PART_SECONDS) SHORTEST_FIRST = 2 # whole seconds: what the first part keeps when the continuing parts take the rest MAX_SECONDS = 7200 MAX_BLOCKS = MAX_SECONDS // SHORTEST_BLOCK +def parts_limit(short, first_frame=False, max_parts=MAX_PARTS): + """The most parts one video may have: fewer when one of its continuing parts is short, unless every + continuing part also sees the first frame.""" + return max_parts if not short or first_frame else min(max_parts, SHORT_MAX_PARTS) + + class PlanError(ValueError): def __init__(self, code, message): super().__init__(message) @@ -35,14 +62,41 @@ def _first_frames(seconds): return geometry(seconds=seconds)['frames'] -def plan(seconds, single=False, max_frames=None, single_limit_seconds=None, block_seconds=None, continued=False, max_parts=None): +def _lengths(total, first, following, least, continued): + """Part lengths in seconds with every continuing part from `least` to `following` seconds, or None.""" + if first < (least if continued else SHORTEST_BLOCK) or following < least: + return None + # The part that starts a new video is not held to `least`; a rest too short for the continuing parts is + # taken from that part. + rest = total if continued else total - min(first, total) + count = math.ceil(round(rest, 6) / following) if rest > 0 else 0 + rest = max(rest, least * count) + whole = math.ceil(round(rest, 6)) + if continued and round(rest - total, 6) > 0 or not continued and total - whole < SHORTEST_FIRST: + return None + if count > MAX_BLOCKS: + raise PlanError('too_long', 'This length needs too many blocks') + lengths = [whole // count + (index < whole % count) for index in range(count)] + if continued: + lengths[0] = round(lengths[0] - (whole - total), 6) # a fractional request keeps its length in the first part + else: + lengths.insert(0, round(total - whole, 6)) + return lengths + + +def plan(seconds, single=False, max_frames=None, single_limit_seconds=None, block_seconds=None, continued=False, + max_parts=None, first_frame=False): """`max_frames`: the most frames this device generates at once for the current settings (unknown: 15 s). `continued`: the video goes on from a saved one, so its first block too spends OVERLAP frames on what came before and adds only the rest. - `max_parts`: the most parts one video may have. A new video that needs more is refused here, before anything - is submitted; a continued one adds to parts this planner does not know, so the server checks it on submission. + `max_parts`: the most parts one video may have; `first_frame`: every continuing part also sees the first + frame, which keeps that limit when a part is short. A video that needs more parts is refused here, before + anything is submitted; a continued one adds to parts this planner does not know, so it counts one for them + and the server checks the rest on submission. + + Continuing parts are FULL_PART_SECONDS or longer whenever the length allows it, else at least MIN_PART_SECONDS. """ _number(seconds, 'Length') if type(single) is not bool: @@ -88,32 +142,31 @@ def plan(seconds, single=False, max_frames=None, single_limit_seconds=None, bloc return alone # a block this short cannot continue another: a short video stays one generation raise PlanError('block_too_short', 'A block that continues another is at least %d s long' % MIN_PART_SECONDS) first, following = min(block, head), min(block, following) - shortest = MIN_PART_SECONDS if continued else SHORTEST_BLOCK - if first < shortest or following < MIN_PART_SECONDS: - if fits and seconds < SINGLE_SECONDS: - return alone # a requested block length never makes a part this device cannot do - raise PlanError('device_limit', 'This device cannot generate this length at this size') # Under 30 s the length stays as asked (it may be fractional); from 30 s it is whole seconds already. total = seconds - # Every part that continues another is at least MIN_PART_SECONDS and at most `following`. The part that - # starts a new video is not held to it; a rest too short for the continuing parts is taken from that part. - rest = total if continued else total - min(first, total) - count = math.ceil(round(rest, 6) / following) if rest > 0 else 0 - rest = max(rest, MIN_PART_SECONDS * count) - whole = math.ceil(round(rest, 6)) - if continued and whole > math.ceil(round(total, 6)) or not continued and total - whole < SHORTEST_FIRST: + lengths = None + for least in (FULL_PART_SECONDS, MIN_PART_SECONDS): + if least < FULL_PART_SECONDS and block >= FULL_PART_SECONDS and fits and seconds < SINGLE_SECONDS: + break # a video that fits at once stays one generation rather than take short parts + lengths = _lengths(total, first, following, least, continued) + if lengths is not None: + break + if lengths is None: if fits and seconds < SINGLE_SECONDS: - return alone + return alone # a requested block length never makes a part this device cannot do + if first < (MIN_PART_SECONDS if continued else SHORTEST_BLOCK) or following < MIN_PART_SECONDS: + raise PlanError('device_limit', 'This device cannot generate this length at this size') raise PlanError('length_not_splittable', 'This length cannot be cut into parts of the requested length') - if count > MAX_BLOCKS: - raise PlanError('too_long', 'This length needs too many blocks') - if max_parts is not None and not continued and count + 1 > max_parts: - raise PlanError('too_many_parts', 'One continuous shot can have up to %d parts. Make it shorter.' % max_parts) - lengths = [whole // count + (index < whole % count) for index in range(count)] - if continued: - lengths[0] = round(lengths[0] - (whole - total), 6) # a fractional request keeps its length in the first part - else: - lengths.insert(0, round(total - whole, 6)) + if max_parts is not None: + parts = len(lengths) + continued # a continued video has at least one part before these + short = any(length < FULL_PART_SECONDS for length in lengths[0 if continued else 1:]) + allowed = parts_limit(short, first_frame, max_parts) + if parts > allowed and allowed < max_parts: + raise PlanError('too_many_short_parts', 'With a part shorter than %d seconds, this video can have up to ' + '%d parts. Make each part at least %d seconds long, or use fewer parts.' + % (FULL_PART_SECONDS, allowed, FULL_PART_SECONDS)) + if parts > allowed: + raise PlanError('too_many_parts', 'One continuous shot can have up to %d parts. Make it shorter.' % max_parts) frames_all = int(round(FPS * total)) blocks, shown = [], 0 for index, length in enumerate(lengths): diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index 37439109..485078b1 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -27,6 +27,18 @@ def __init__(self, parts, limit): self.message_zh = '一个连续镜头最多 %d 段,请缩短。' % limit +class ChainShortPartsTooMany(ChainTooLong): + """A video with a short continuing part, continued through more parts than such a video may have.""" + code = 'chain_short_parts' + + def __init__(self, parts, limit): + full = chain_plan.FULL_PART_SECONDS + ValueError.__init__(self, 'With a part shorter than %d seconds, this video can have up to %d parts. ' + 'Make each part at least %d seconds long, or use fewer parts.' % (full, limit, full)) + self.parts, self.limit = parts, limit + self.message_zh = '有短于 %d 秒的段时,这条视频最多 %d 段。请把每段加长到至少 %d 秒,或减少段数。' % (full, limit, full) + + class ChainPartTooShort(ValueError): """A continuing part shorter than a continued video may have.""" code = 'chain_part_too_short' @@ -34,7 +46,7 @@ class ChainPartTooShort(ValueError): def __init__(self, seconds, limit): super().__init__('Each continued part must be at least %d seconds. Make it longer.' % limit) self.seconds, self.limit = seconds, limit - self.message_zh = '接着生成的每一段至少 %d 秒,请加长一些。' % limit + self.message_zh = '接着生成的每一段至少 %d 秒,请加长这一段。' % limit def _work_key(path): @@ -69,11 +81,15 @@ def node_prompt(prompt, node): class ChainRunner: def __init__(self, store_dir, host, clock=time.monotonic, wall=time.time, estimate=None, poll=0.5, inputs=None, - max_parts=None, min_part_frames=None, seeds=None): + max_parts=None, min_part_frames=None, seeds=None, full_part_frames=None): self.store_dir, self.host = store_dir, host self.seeds = seeds or random.SystemRandom() self.max_parts = max_parts or store.MAX_PARTS self.min_part_frames = chain_plan.MIN_PART_FRAMES if min_part_frames is None else min_part_frames + # Continuing parts below this are short and hold their video to fewer parts. A runner given a lower + # shortest part (a test host) lowers this with it unless it is given too. + self.full_part_frames = (full_part_frames if full_part_frames is not None else chain_plan.FULL_PART_FRAMES + if min_part_frames is None else min(min_part_frames, chain_plan.FULL_PART_FRAMES)) self.clock, self.wall, self.estimate, self.poll = clock, wall, estimate, poll self.inputs = inputs or host.inputs self.lock = threading.RLock() @@ -219,33 +235,45 @@ def _depths(self, record, seen=()): """Parts each block of `record` continues through, itself included (a block with no source is the first part). A source that is an existing video counts the parts that video was made of, as far as this runner's records know them; one they do not know counts as one part.""" + return {name: parts for name, (parts, _) in self._lineage(record, seen).items()} + + def _lineage(self, record, seen=()): + """For each block of `record`: its parts as `_depths` counts them, and whether any continuing part + among them is short.""" rows = {row['id']: row for row in record['blocks']} - depth, pending = {}, list(rows) + lineage, pending = {}, list(rows) while pending: rest = [] for identity in pending: row = rows[identity] sources = [row[key] for key in ('after', 'before') if row[key] is not None] - if any(isinstance(value, str) and value not in depth for value in sources): + if any(isinstance(value, str) and value not in lineage for value in sources): rest.append(identity) continue - depth[identity] = 1 + sum(depth[value] if isinstance(value, str) - else self._source_parts(value, seen + (record['id'],)) - for value in sources) + found = [lineage[value] if isinstance(value, str) else self._source_lineage(value, seen + (record['id'],)) + for value in sources] + short = bool(sources) and row['inputs']['frames'] < self.full_part_frames + lineage[identity] = (1 + sum(parts for parts, _ in found), short or any(flag for _, flag in found)) if len(rest) == len(pending): raise ValueError('Block references cannot contain a cycle or self reference') pending = rest - return depth + return lineage def _source_parts(self, value, seen): + return self._source_lineage(value, seen)[0] + + def _source_lineage(self, value, seen): if 'chain' not in value: - return self._work_parts(value['work'], seen) + return self._work_lineage(value['work'], seen) self._referenced_block(value) if value['chain'] in seen: raise ValueError('Chain references cannot contain a cycle') - return self._depths(self.records[value['chain']], seen)[value['block']] + return self._lineage(self.records[value['chain']], seen)[value['block']] def _work_parts(self, work, seen): + return self._work_lineage(work, seen)[0] + + def _work_lineage(self, work, seen): key = _work_key(work) for record in list(self.records.values()): if record['id'] in seen: @@ -255,9 +283,27 @@ def _work_parts(self, work, seen): else: names = [row['id'] for row in record['blocks'] if row['output'] and _work_key(row['output']) == key] if names: - depth = self._depths(record, seen) - return max(depth[name] for name in names) - return 1 + lineage = self._lineage(record, seen) + return max(lineage[name][0] for name in names), any(lineage[name][1] for name in names) + return 1, False + + def _check_parts(self, record): + """Refuses, before anything is written or queued, a chain with a continuing part too short or a video + continued through too many parts.""" + short = [row['inputs']['frames'] for row in record['blocks'] + if (row['after'] is not None or row['before'] is not None) + and row['inputs']['frames'] < self.min_part_frames] + if short: + raise ChainPartTooShort(min(short) / chain_plan.FPS, chain_plan.MIN_PART_SECONDS) + lineage = self._lineage(record).values() + parts = max(parts for parts, _ in lineage) + if parts > self.max_parts: + raise ChainTooLong(parts, self.max_parts) + inputs = record['snapshot']['prompt'][record['node']].get('inputs') or {} + limit = chain_plan.parts_limit(True, inputs.get('first_frame_reference') is True, self.max_parts) + parts = max((parts for parts, flag in lineage if flag), default=0) + if parts > limit: + raise ChainShortPartsTooMany(parts, limit) def create(self, payload): from .lora_triggers import apply_chain_triggers @@ -265,14 +311,7 @@ def create(self, payload): apply_chain_triggers(record) with self.lock: # Before anything is written or queued: a refused chain leaves every record and file as it was. - short = [row['inputs']['frames'] for row in record['blocks'] - if (row['after'] is not None or row['before'] is not None) - and row['inputs']['frames'] < self.min_part_frames] - if short: - raise ChainPartTooShort(min(short) / chain_plan.FPS, chain_plan.MIN_PART_SECONDS) - parts = max(self._depths(record).values()) - if parts > self.max_parts: - raise ChainTooLong(parts, self.max_parts) + self._check_parts(record) record['created'] = self.wall() self._save(record) self.records[record['id']] = record @@ -410,14 +449,7 @@ def redo(self, chain_id, block_id, text=None, client_id=None): except Exception: raise ValueError('Text must be at most 20000 characters with LoRA triggers') from None record['blocks'][start['index']]['inputs']['text'] = probe['blocks'][0]['inputs']['text'] - short = [row['inputs']['frames'] for row in record['blocks'] - if (row['after'] is not None or row['before'] is not None) - and row['inputs']['frames'] < self.min_part_frames] - if short: - raise ChainPartTooShort(min(short) / chain_plan.FPS, chain_plan.MIN_PART_SECONDS) - parts = max(self._depths(record).values()) - if parts > self.max_parts: - raise ChainTooLong(parts, self.max_parts) + self._check_parts(record) # The kept seams are repaired with the sound the original chain kept for them. Copies left by # a failed save have no record, so the next start prunes them. try: diff --git a/freevideo_engine/chain_store.py b/freevideo_engine/chain_store.py index d7e536f4..7dd23902 100644 --- a/freevideo_engine/chain_store.py +++ b/freevideo_engine/chain_store.py @@ -26,8 +26,8 @@ 'unavailable': 'Splitting is not available here, so every part used the whole description.', 'failed': 'Splitting the description failed, so every part used the whole description.', } -# Parts one video may be continued through: the first part and seven continuations. -MAX_PARTS = 8 +# Parts one video may be continued through: the first part and seven continuations (fewer with a short part). +MAX_PARTS = chain_plan.MAX_PARTS def reference(value, queue=False): diff --git a/freevideo_engine/comfy_chain.py b/freevideo_engine/comfy_chain.py index aa4b5cb2..1b99620d 100644 --- a/freevideo_engine/comfy_chain.py +++ b/freevideo_engine/comfy_chain.py @@ -5,6 +5,7 @@ import math import os from pathlib import Path, PurePosixPath +import platform import ssl import time from urllib import error, request @@ -248,6 +249,14 @@ def continuation_supported(): return supported()[0] +def continue_seconds(): + """The length a new continuation starts with where this device sets it: short parts on Apple silicon, where + a full part takes long and often does not fit; None elsewhere, where the page goes by the measured limits.""" + if platform.system() == 'Darwin' and platform.machine().lower() in ('arm64', 'aarch64'): + return chain_plan.SHORT_CONTINUE_SECONDS + return None + + def continuation_available(): return 'after' in continuation_directions() and continuation_supported() @@ -333,7 +342,8 @@ async def plan(request): value = await body(request) result = chain_plan.plan( value.get('seconds'), value.get('single', False), value.get('max_frames'), - value.get('single_limit_seconds'), value.get('block_seconds'), max_parts=runner.max_parts) + value.get('single_limit_seconds'), value.get('block_seconds'), max_parts=runner.max_parts, + first_frame=value.get('first_frame_reference') is True) if not result['single'] and not continuation_supported(): # A device that cannot continue a video has one block to give: never a plan in parts. raise chain_plan.PlanError('device_limit', 'This device cannot generate this length at this size') @@ -376,7 +386,8 @@ def asking(request): @server.routes.get('/freevideo/chains') async def listing(request): - value = {'chains': runner.list(asking(request)), 'continuation': continuation_available()} + value = {'chains': runner.list(asking(request)), 'continuation': continuation_available(), + 'continue_seconds': continue_seconds()} if fake: value['fake'] = True return response(value) diff --git a/web/chain_rules.js b/web/chain_rules.js new file mode 100644 index 00000000..b0575bfe --- /dev/null +++ b/web/chain_rules.js @@ -0,0 +1 @@ +export const CHAIN_RULES = {"min_part_seconds": 5, "full_part_seconds": 10, "max_parts": 8, "short_max_parts": 4, "continue_seconds": 10, "short_continue_seconds": 5}; diff --git a/web/editor.js b/web/editor.js index eb1fdd1c..9b345a1f 100644 --- a/web/editor.js +++ b/web/editor.js @@ -43,6 +43,15 @@ const editorStyles = Promise.all(['result_actions.css', 'editor.css', 'editor_ex })); const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; const language = () => new URLSearchParams(location.search).get('freevideo_lang') || navigator.language; +// The engine's limits on continuing parts, refused before a generation starts; the engine says the same. +export const PARTS_LIMIT_CODES = ['chain_too_long', 'chain_part_too_short', 'chain_short_parts']; +export function partsLimitText(code, t = en => en, limit = null) { + const {followSeconds: least, fullSeconds: full, maxParts, shortMaxParts} = model.RULES; + if (code === 'chain_part_too_short') return t(`Each continued part must be at least ${least} seconds. Make it longer.`, `接着生成的每一段至少 ${least} 秒,请加长这一段。`); + if (code === 'chain_short_parts') { const most = limit ?? shortMaxParts; return t(`With a part shorter than ${full} seconds, this video can have up to ${most} parts. Make each part at least ${full} seconds long, or use fewer parts.`, + `有短于 ${full} 秒的段时,这条视频最多 ${most} 段。请把每段加长到至少 ${full} 秒,或减少段数。`); } + return t(`One continuous shot can have up to ${limit ?? maxParts} parts. Make it shorter.`, `一个连续镜头最多 ${limit ?? maxParts} 段,请缩短。`); +} // Shared by error() and actions.reasonText: one sentence naming the failed step. export function editorErrorText(value, t = en => en) { if (isNetworkTypeError(value) || isNetworkTypeError(value?.detail)) return connectionLostText(t); @@ -61,8 +70,10 @@ export function editorErrorText(value, t = en => en) { if (code === 'gpu_memory' || /out of memory|cuda.*memory|显存不足|显存不够/i.test(raw)) return t(`${stage}: not enough GPU memory.`, `${stage}时显存不足。`); const known = { - chain_part_too_short: t('Each continued part needs at least 10 seconds.', '接续的每段至少 10 秒。'), - chain_too_long: t('One continuous shot can have up to 8 parts.', '一个连续镜头最多 8 段。'), + chain_part_too_short: t(`Each continued part needs at least ${model.RULES.followSeconds} seconds.`, `接续的每段至少 ${model.RULES.followSeconds} 秒。`), + chain_short_parts: t(`With a part shorter than ${model.RULES.fullSeconds} seconds, one video can have up to ${model.RULES.shortMaxParts} parts.`, + `有短于 ${model.RULES.fullSeconds} 秒的段时,一条视频最多 ${model.RULES.shortMaxParts} 段。`), + chain_too_long: t(`One continuous shot can have up to ${model.RULES.maxParts} parts.`, `一个连续镜头最多 ${model.RULES.maxParts} 段。`), too_long: t('This part is longer than the maximum length. Shorten it and try again.', '这一段超过了最长时长,请缩短后再试。'), missing_node: t('Generation is not ready. Restart FreeVideo.', '生成功能没有就绪,请重新启动 FreeVideo。'), chain_missing: t('Generation could not start. Choose "Generate" again.', '生成没能开始,请再点“生成”。'), @@ -305,10 +316,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given } if (['audio_prepare', 'speed_pending', 'speed_locked', 'speed'].includes(code)) return {ok: false, reason: code}; if (code === 'unsupported') { genbar?.update(null); return; } - if (['chain_too_long', 'chain_part_too_short'].includes(code)) { + if (PARTS_LIMIT_CODES.includes(code)) { const [en, zh] = (value.said || []).map(text => typeof text === 'string' && text ? text.slice(0, 200) : null); - const text = code === 'chain_too_long' ? t(en || 'One continuous shot can have up to 8 parts. Make it shorter.', zh || '一个连续镜头最多 8 段,请缩短。') - : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接着生成的每一段至少 10 秒,请加长一些。'); + const text = en && zh ? t(en, zh) : partsLimitText(code, t, Number.isInteger(value.limit) ? value.limit : null); out(text); return {ok: false, reason: code, say: text}; } if (['busy', 'locked', 'text', 'audio_full', 'overlap', 'range', 'no_audio'].includes(code)) { @@ -490,7 +500,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given // Each part goes with its own description, as written while it waited. const texts = Object.fromEntries(clips.map(clip => [clip.id, clip.text.trim() || entry.text])); try { await actions.generate({text: texts[clips[0].id], texts, selection}); } - catch (value) { const reason = error(value, 'generate'); if (reason && !['chain_too_long', 'chain_part_too_short'].includes(reason.reason)) genbar?.say(reason.say); } + catch (value) { const reason = error(value, 'generate'); if (reason && !PARTS_LIMIT_CODES.includes(reason.reason)) genbar?.say(reason.say); } } } finally { startingHeld = false; } } @@ -929,6 +939,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given submittedSelection = shown && state.selection === chosen ? state.select({kind: 'clip', ids}) : state.selection; await completeSources(); const paths = Object.fromEntries([...store.sources].map(([key, value]) => [key, value.path])); const request = model.generationRequest(prepared, ids, {text: texts ? undefined : text, paths, queue: true}); + // What the engine would refuse for its continuing parts is said here, before anything is sent. + const problem = model.partsProblem(prepared, ids); + if (problem) throw Object.assign(new Error(problem.code), {code: problem.code, limit: problem.limit}); const beforeKeys = model.generationBefore(prepared, ids), sizes = new Map(store.sources); for (const key of beforeKeys.filter(key => key?.startsWith('chain:'))) { const job = await store.chain(key.split(':')[1]); @@ -973,7 +986,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (epoch === projectEpoch) { if (ids.length) state.settle(record => model.settle(record, ids, 'draft')); // A length limit is the user's to change and is said once in the notice; there is nothing to report. - if (!['chain_too_long', 'chain_part_too_short'].includes(value?.code)) { + if (!PARTS_LIMIT_CODES.includes(value?.code)) { failures.submission(value, frozen, text); submissionFailure = JSON.stringify(state.selection); } genbar?.update(null); @@ -1523,7 +1536,8 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (after.length || !clip.extend || !clip.source || store.sources.get(sourceKey(clip.source))?.missing) return true; if (!store.generationAvailable) return false; const spare = Math.min(...model.pair(state.record, clip.id).map(member => member.length - member.in - member.frames)); - tracks.extendClip(clip.id, spare + model.continuedSeconds(0, capability.maxFollowSeconds) * model.FPS, {silent: true}); + tracks.extendClip(clip.id, spare + model.defaultContinueSeconds(capability.maxFollowSeconds, + {short: store.continueSeconds != null && store.continueSeconds < model.RULES.fullSeconds}) * model.FPS, {silent: true}); return true; }; await store.generationReady(); diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 823e22bc..1e4a5b12 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -247,7 +247,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { } // A specific reason is said once; the planning limits are already shown above. catch (error) { const result = actions.error(error, 'generate'); reason = result?.say || ''; - if (result?.say && !['chain_too_long', 'chain_part_too_short'].includes(result.reason)) say(result.say, false, ['Editor generation', '剪辑器生成']); + if (result?.say && !['chain_too_long', 'chain_part_too_short', 'chain_short_parts'].includes(result.reason)) say(result.say, false, ['Editor generation', '剪辑器生成']); else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '没能开始,请重试。'); say(reason, false, ['Editor generation', '剪辑器生成']); } } finally { pressing.delete(key); render(); } return reason; diff --git a/web/editor_model.js b/web/editor_model.js index dbd62237..8439c36f 100644 --- a/web/editor_model.js +++ b/web/editor_model.js @@ -4,12 +4,18 @@ import {pictureFields, pictureEditor} from './editor_picture_fields.js'; import {markerFields} from './editor_transport_model.js'; import {audioClipFields, audioTrackFields, normalizeAudioEdits, splitAudioFields} from './editor_audio_model.js'; import {speedRange, parseSpeedData, validateSpeedPair, sourceFrame, speedEdited, trimSpeed, splitSpeed, connectSpeedOperations} from './editor_speed_model.js'; +import {CHAIN_RULES} from './chain_rules.js'; export const VERSION = 1; export const FPS = 24; -// followSeconds mirrors chain_plan.MIN_PART_SECONDS: a part that continues another is at least this long. -export const RULES = Object.freeze({maxSeconds: 15, singleSeconds: 30, minSeconds: 2, tailSeconds: 5, followSeconds: 10, +// The continuing-part limits are chain_plan's (web/chain_rules.js): a part that continues another is at least +// followSeconds long; a video with a continuing part shorter than fullSeconds has at most shortMaxParts parts unless +// every part sees the first frame, any other at most maxParts. A new continuation starts at continueSeconds, or at +// shortContinueSeconds where a full part is too much for the device. +export const RULES = Object.freeze({maxSeconds: 15, singleSeconds: 30, minSeconds: 2, tailSeconds: 5, + followSeconds: CHAIN_RULES.min_part_seconds, fullSeconds: CHAIN_RULES.full_part_seconds, + continueSeconds: CHAIN_RULES.continue_seconds, shortContinueSeconds: CHAIN_RULES.short_continue_seconds, step: 17, first: 5, overlap: 22, - maxTracks: 3, maxClips: 500, maxText: 8000, maxParts: 8}); + maxTracks: 3, maxClips: 500, maxText: 8000, maxParts: CHAIN_RULES.max_parts, shortMaxParts: CHAIN_RULES.short_max_parts}); // Slice and bound user text by Unicode code points, never UTF-16 halves. export const truncateText = (text, limit) => Array.from(text).slice(0, limit).join(''); export const copyName = (name, suffix) => truncateText(name, RULES.maxText - Array.from(suffix).length) + suffix; @@ -634,6 +640,12 @@ export const setPictureSize = (record, width, height, size_mode = 'fixed') => fi export const setPicture = pictureEditor({copy, trackBy, check, pictureFit, finish}); export const setPictureFit = (record, id, fit) => setPicture(record, id, {fit: pictureFit(fit)}); +// The fewest frames a continuing part of `seconds` whole seconds asks for (chain_plan.part_frames): below +// MIN_PART_FRAMES a continuing part is too short, below FULL_PART_FRAMES it is a short part. +const partFrames = seconds => RULES.overlap + RULES.step * Math.ceil((FPS * seconds - (RULES.step - 1)) / RULES.step); +export const MIN_PART_FRAMES = partFrames(RULES.followSeconds), FULL_PART_FRAMES = partFrames(RULES.fullSeconds); +// The most parts one video may have (chain_plan.parts_limit). +export const partsLimit = (short, firstFrame = false) => short && !firstFrame ? Math.min(RULES.maxParts, RULES.shortMaxParts) : RULES.maxParts; // The shortest a part that continues another may be; a device that cannot make a part that long keeps the general minimum. const followLeast = maxFollowSeconds => maxFollowSeconds >= RULES.followSeconds ? RULES.followSeconds : RULES.minSeconds; // A length to continue by, in whole seconds, raised to one that parts of followSeconds..maxFollowSeconds can add up to. @@ -643,6 +655,12 @@ export function continuedSeconds(seconds, maxFollowSeconds = RULES.maxSeconds) { const length = Math.max(least, Math.round(seconds)); return Math.max(length, Math.ceil(length / follow) * least); } +// The length a new continuation starts with: a full part, or a short one where the device cannot make a full part +// or `short` asks for it. +export function defaultContinueSeconds(maxFollowSeconds = RULES.maxSeconds, {short = false} = {}) { + const full = !short && maxFollowSeconds >= RULES.fullSeconds; + return continuedSeconds(full ? RULES.continueSeconds : RULES.shortContinueSeconds, maxFollowSeconds); +} function segmentLengths(frames, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds, continues = false) { check(typeof maxSeconds === 'number' && Number.isFinite(maxSeconds) && maxSeconds >= RULES.minSeconds, 'range'); check(typeof maxFollowSeconds === 'number' && Number.isFinite(maxFollowSeconds) && maxFollowSeconds >= RULES.minSeconds, 'range'); @@ -652,13 +670,15 @@ function segmentLengths(frames, maxSeconds = RULES.maxSeconds, maxFollowSeconds // half a minute or more, or beyond the device, goes in parts of a quarter minute. if (!continues && left < RULES.singleSeconds * FPS && left <= Math.floor(maxSeconds) * FPS) return [left]; check(left <= RULES.maxClips * max, 'range'); - // Every part after the first continues the one before it and is at least followSeconds long, as chain_plan lays - // them out: the parts after the first share what is left evenly, and a rest too short for them comes off the first. - if (follow >= RULES.followSeconds * FPS) { - const seconds = continues ? continuedSeconds(left / FPS, follow / FPS) : left / FPS; + // Every part after the first continues the one before it, as chain_plan lays them out: the parts after the first + // share what is left evenly, and a rest too short for them comes off the first. They are fullSeconds or longer + // whenever the length allows it, else at least followSeconds. + const seconds = continues && follow >= RULES.followSeconds * FPS ? continuedSeconds(left / FPS, follow / FPS) : left / FPS; + for (const least of [RULES.fullSeconds, RULES.followSeconds]) { + if (follow < least * FPS) continue; const head = continues ? 0 : Math.min(max / FPS, seconds), rest = seconds - head, count = Math.ceil(rest / (follow / FPS)); - const shared = Math.max(rest, RULES.followSeconds * count); - if (continues || seconds - shared >= RULES.minSeconds) { + const shared = Math.max(rest, least * count); + if (continues ? shared === rest : seconds - shared >= RULES.minSeconds) { check(count <= RULES.maxClips, 'range'); const result = continues ? [] : [(seconds - shared) * FPS]; for (let i = 0; i < count; i++) result.push((Math.floor(shared / count) + (i < shared % count ? 1 : 0)) * FPS); @@ -718,12 +738,14 @@ export function continuesBefore(record, id) { } // The lengths one range is generated in, as planRange lays them out on empty tracks. export const partLengths = (frames, {maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds, continues = false} = {}) => segmentLengths(frames, maxSeconds, maxFollowSeconds, continues); -// One video is generated in at most RULES.maxParts parts: the longest range that still fits. -export function maxRangeFrames(record, {continues = false, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds}) { +// One video is generated in at most partsLimit parts: the longest range that still fits. A device whose continuing +// parts are all short has the short-part limit. +export function maxRangeFrames(record, {continues = false, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds, firstFrame = false}) { const before = continues; const follow = Math.floor(Math.min(RULES.maxSeconds, maxFollowSeconds)) * FPS; + const parts = partsLimit(follow < RULES.fullSeconds * FPS, firstFrame); // The part being continued is the first of them. - return before ? (RULES.maxParts - 1) * follow : Math.floor(Math.min(RULES.maxSeconds, maxSeconds)) * FPS + (RULES.maxParts - 1) * follow; + return before ? (parts - 1) * follow : Math.floor(Math.min(RULES.maxSeconds, maxSeconds)) * FPS + (parts - 1) * follow; } // Manual boundaries use device limits, not the automatic fifteen-second preference. export function rangeParts(record, {start, frames, parts, continues, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds}) { @@ -1041,6 +1063,68 @@ export function generationRequest(record, ids, {text, paths = {}, queue = false} } return frozen({blocks, plan}); } +// Whether a part made before a request is a short continuing part: what the engine kept of it is its new frames. +const madeShort = clip => (clip.source ? clip.length : clip.frames) + RULES.overlap < FULL_PART_FRAMES; +// The parts the finished or queued part `clip` was continued through on the timeline, itself included, and whether +// a continuing part among them is short. +function madeLineage(record, clip) { + let parts = 1, short = false; + for (let before = partBefore(record, clip), seen = new Set([clip.id]); before && !seen.has(before.id); before = partBefore(record, before)) { + short = short || madeShort(clip); parts += 1; seen.add(before.id); clip = before; + } + return {parts, short}; +} +// What the engine refuses in a generation request (chain_runner checks the same before anything runs): a continuing +// part shorter than followSeconds, a video continued through more than maxParts parts, or one with a short +// continuing part through more than partsLimit allows. Parts made before the request count as the timeline shows +// them. Returns {code, parts, limit} for the first problem, or null. +export function partsProblem(record, ids, {firstFrame = record.settings?.first_frame_reference === true} = {}) { + const rows = generationPlan(record, generationClips(record, ids)); + if (rows.some(row => row.before && row.request_frames < MIN_PART_FRAMES)) return {code: 'chain_part_too_short', parts: null, limit: RULES.followSeconds}; + const lineage = new Map(); + for (const row of rows) { + const previous = !row.before ? {parts: 0, short: false} : lineage.get(row.before.id) ?? madeLineage(record, row.before); + lineage.set(row.clip.id, {parts: previous.parts + 1, short: previous.short || !!row.before && row.request_frames < FULL_PART_FRAMES}); + } + const values = [...lineage.values()], parts = Math.max(...values.map(value => value.parts)); + if (parts > RULES.maxParts) return {code: 'chain_too_long', parts, limit: RULES.maxParts}; + const limit = partsLimit(true, firstFrame), short = Math.max(0, ...values.filter(value => value.short).map(value => value.parts)); + return short > limit ? {code: 'chain_short_parts', parts: short, limit} : null; +} +// Sets the continuation draft `id` (a planned part that continues the one before it) to `seconds` whole seconds, +// keeping its start; the drafts that continue it move with its end. Refused with 'range' for a length a continuing +// part cannot have on this device or where there is no room, and with the engine's code (partsProblem) where the +// new length would make the video one the engine refuses. +export function setContinuationSeconds(record, id, seconds, {maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds, firstFrame} = {}) { + const members = pair(record, id); check(members.length, 'missing'); busy(members); + check(members.every(clip => clip.pending && !clip.source), 'unsupported'); + const video = members.find(clip => trackOf(record, clip.id).id === 'v1'); + check(!!video && !!partBefore(record, video), 'unsupported'); + check(Number.isInteger(seconds) && seconds >= followLeast(maxFollowSeconds) + && seconds <= Math.floor(Math.min(RULES.maxSeconds, maxFollowSeconds)), 'range'); + const delta = seconds * FPS - video.frames; + if (!delta) return record; + const run = [video]; + for (let next; (next = trackBy(record, 'v1').clips.find(clip => clip.pending && !clip.source && continuing(clip, run.at(-1)))); ) run.push(next); + const moving = run.flatMap(clip => pair(record, clip.id)); busy(moving); + const moved = new Set(moving.map(clip => clip.id)); + let next = copy(record); detachAttempt(next, [...moved]); + for (const clip of moving) { + const changed = find(next, clip.id); + if (pair(record, video.id).some(member => member.id === clip.id)) { changed.frames = changed.length = seconds * FPS; clearPlan(changed); } + else { changed.start += delta; clearPlan(changed); } + // Nothing else on the track may be under the part's new span. + check(!trackOf(next, clip.id).clips.some(other => !moved.has(other.id) && overlaps(other, changed.start, changed.frames)), 'range'); + } + next = finish(next); + // The drafts it continues are generated with it, so the whole video is checked. + const earlier = []; + for (let before = partBefore(record, video); before && before.pending === 'draft' && !before.source && !earlier.includes(before); before = partBefore(record, before)) earlier.unshift(before); + const ids = [...earlier, ...run].map(clip => clip.id), options = firstFrame === undefined ? {} : {firstFrame}; + const problem = partsProblem(next, ids, options); + if (problem && problem.code !== partsProblem(record, ids, options)?.code) throw Object.assign(new EditError(problem.code), {parts: problem.parts, limit: problem.limit}); + return next; +} function update(record, ids, change) { const next = copy(record), selected = linkedIds(next, ids); for (const clip of all(next)) if (selected.has(clip.id)) change(clip); diff --git a/web/editor_store.js b/web/editor_store.js index f3ab5744..9590f9bd 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -176,6 +176,8 @@ export function mediaURL(api, value) { export function createEditorStore(api, {t = en => en, notify = () => {}, onGenerationReady = () => {}, onGenerationChecking = () => {}, clock = clockDefault, pollMs = 2000, standIn: injected = null} = {}) { const sources = new Map(), sourceReads = new Map(), watchers = new Set(), pollers = new Set(), once = new Set(); let recovered = null, capabilityPoll = null; + // The length a new continuation starts with where the engine names one (a device that continues in short parts). + let continueSeconds = null; let generationAvailable = false, generationGone = false, generationProbe = null, generationRetry = null, generationAttempts = 0, generationStalled = false, generationAsking = false; let disposed = false; const uploads = new Set(); const session = holdSession(); @@ -260,6 +262,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener memoryProjects.delete(id); if (activeProject === id) { activeProject = null; clock.clearTimeout(sessionTimer); sessionTimer = null; } }, + get continueSeconds() { return standIn ? standIn.continueSeconds ?? null : continueSeconds; }, get generationAvailable() { return standIn ? standIn.generationAvailable ?? true : generationAvailable; }, // The server said for certain it cannot generate (not just a missed answer that is asked again). get generationGone() { return !standIn && generationGone; }, @@ -292,8 +295,13 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener try { // true: present; false: no such route; null: no usable answer. answer = await Promise.race([Promise.resolve().then(() => request('/freevideo/chains', {signal: controller.signal})) - .then(async response => response.status === 404 ? false - : response.ok && Array.isArray((await response.json())?.chains) ? true : null) + .then(async response => { + if (response.status === 404) return false; + if (!response.ok) return null; + const body = await response.json(); + if (Number.isInteger(body?.continue_seconds) && body.continue_seconds > 0) continueSeconds = body.continue_seconds; + return Array.isArray(body?.chains) ? true : null; + }) .catch(() => null), timeout]); } finally { probe.dispose(); generationProbe = null; generationAsking = false; } if (disposed || generationGone) return false; @@ -473,7 +481,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener if (!response.ok) { // The server's own sentence for a video with too many parts, or a continued part too short, is shown as it is. const body = await response.json().catch(() => null); - if (['chain_too_long', 'chain_part_too_short'].includes(body?.code)) throw Object.assign(new Error(body.code), {code: body.code, said: [body.error, body.error_zh]}); + if (['chain_too_long', 'chain_part_too_short', 'chain_short_parts'].includes(body?.code)) throw Object.assign(new Error(body.code), {code: body.code, said: [body.error, body.error_zh]}); throw Object.assign(new Error(typeof body?.error === 'string' ? body.error : body?.message || `HTTP ${response.status}`), {status: response.status, reason: body?.reason || body?.code, response: body}); } diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 6f80e2e3..0006e5db 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -85,7 +85,8 @@ export function edgeParameters(clip, edge, frame, points, zoom, {canExtend = tru if (edge === 'end' && canExtend && clip.extend && snapped.frame > sourceEnd) { // What continues a video is at least followSeconds long; the preview already shows the raised length. const wanted = Math.round((snapped.frame - sourceEnd) / model.FPS), seconds = model.continuedSeconds(wanted, maxFollowSeconds); - return {kind: 'extend', frame: sourceEnd, frames: seconds * model.FPS, guide: snapped.guide, raised: seconds !== wanted}; + return {kind: 'extend', frame: sourceEnd, frames: seconds * model.FPS, guide: snapped.guide, raised: seconds !== wanted, + least: wanted < model.RULES.followSeconds && seconds >= model.RULES.followSeconds}; } return {kind: 'trim', edge, frame: snapped.frame, guide: snapped.guide}; } @@ -959,7 +960,9 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = else if (result.kind === 'extend') { const frames = result.frame - finished.clip.start - finished.clip.frames + result.frames; if (extendClip(finished.id, frames) && result.raised) { - actions.notify?.(t(`Extended to ${result.frames / model.FPS} s.`, `已补到 ${result.frames / model.FPS} 秒。`)); + const least = model.RULES.followSeconds; + actions.notify?.(result.least ? t(`Each continued part needs at least ${least} seconds.`, `接着生成的每一段至少 ${least} 秒。`) + : t(`Extended to ${result.frames / model.FPS} s.`, `已补到 ${result.frames / model.FPS} 秒。`)); } } else attempt(t('Trim', '裁短'), record => model.trim(record, finished.id, finished.edge, result.frame)); renderSelection(); diff --git a/web/health.js b/web/health.js index ad011fe8..c511e8d0 100644 --- a/web/health.js +++ b/web/health.js @@ -13,7 +13,7 @@ const t = (en, zh) => cn ? zh : en; // launcher.js removes this flag during setup; read it before that. const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. -export const MODULES = ['chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.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', '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', +export const MODULES = ['chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.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_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', '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_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'issue_button.js', 'studio_recent.js', diff --git a/web/howto.js b/web/howto.js index a291e927..86c2e98d 100644 --- a/web/howto.js +++ b/web/howto.js @@ -35,7 +35,8 @@ const HOWTOS = [ zh: '填写更长的时长,会自动分段生成。'}, {id: 'generate', anchor: '[data-fv-guide="generate"]', en: 'Choose "Generate video".', zh: '点“生成视频”。'}, {id: 'extend', anchor: '.fv-action-extend', image: 'extend-button', - en: 'Choose "Extend" on a finished video to continue it.', zh: '在生成好的视频上点“接着生成”,继续下一段。'}, + en: 'Choose "Extend" on a finished video to continue it. Each continued part is at least 5 seconds; with a part shorter than 10 seconds, one video has at most 4 parts.', + zh: '在生成好的视频上点“接着生成”,继续下一段。接着生成的每一段至少 5 秒;有短于 10 秒的段时,一条视频最多 4 段。'}, {id: 'longer', anchor: '[data-view="editor"]', en: 'Parts can also be added one by one in "Edit".', zh: '也可在“剪辑视图”中逐段接续。'}]}, {id: 'editor-extend-refs', view: 'editor', title: ['Add reference images when extending in Edit', '在剪辑视图延长时加参考图'], steps: [ diff --git a/web/length_plan.js b/web/length_plan.js index 525405c5..d1b28ea5 100644 --- a/web/length_plan.js +++ b/web/length_plan.js @@ -32,7 +32,7 @@ export function readLengthPlan(reply) { export function classifyPlanError(reply) { if (reply?.code === 'device_limit') return 'continuation_unavailable'; - if (reply?.code === 'too_long' || reply?.code === 'too_many_parts') return 'too_long'; + if (reply?.code === 'too_long' || ['too_many_parts', 'too_many_short_parts'].includes(reply?.code)) return 'too_long'; if (['block_too_long', 'block_too_short', 'too_short', 'invalid'].includes(reply?.code)) return 'other'; const value = reply?.error; const message = reply?.code || (typeof value === 'string' ? value : value?.code || value?.message || ''); diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index 5d5d901e..6735bbc8 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -33,8 +33,8 @@ export const TUTORIAL_CHAPTERS = [ en: ['Enter a longer duration. It is made in parts automatically. "Available sizes and lengths" shows how long each size can be and about how long it takes.', 'Choose "Generate video".', 'Choose "Extend" on a finished video to continue it.', 'Parts can also be added one by one in "Edit".'], }, limits: { - zh: ['一个连续镜头最多 8 段,创作面板中时长最长 60 秒;分镜剧本可在剪辑视图中逐个镜头添加,或用 AI 全自动。', '分段生成和接着生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], - en: ['One continuous shot has at most 8 parts, and the creation panel accepts up to 60 seconds. For a storyboard, add shots one by one in Edit, or use Autopilot.', 'Generating in parts and Extend need an NVIDIA graphics card or a Mac.', 'The more parts are joined, the more the joins may change. Where a cut fits, cut to a new shot and start a fresh part.'], + zh: ['接着生成的每一段至少 5 秒。一个连续镜头最多 8 段,有短于 10 秒的段时最多 4 段;创作面板中时长最长 60 秒;分镜剧本可在剪辑视图中逐个镜头添加,或用 AI 全自动。', '分段生成和接着生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], + en: ['Each continued part is at least 5 seconds. One continuous shot has at most 8 parts, or 4 when a part is shorter than 10 seconds, and the creation panel accepts up to 60 seconds. For a storyboard, add shots one by one in Edit, or use Autopilot.', 'Generating in parts and Extend need an NVIDIA graphics card or a Mac.', 'The more parts are joined, the more the joins may change. Where a cut fits, cut to a new shot and start a fresh part.'], }, }, { @@ -51,8 +51,8 @@ export const TUTORIAL_CHAPTERS = [ en: ['Select the part to continue.', 'Click + next to the prompt to upload or generate images, up to 3.', 'Describe it, then choose "Generate".'], }, limits: { - zh: ['接续生成最多使用 3 张参考图,不使用首帧和尾帧。', '剪辑视图只能添加图片;参考视频和音频在创作面板的“素材”中添加。', '同样最多 8 段;需要 NVIDIA 显卡或 Mac。'], - en: ['A part that continues the previous one uses up to 3 reference images and no first or last frames.', 'Edit takes images only. Add reference videos and audio under "Media" in the creation panel.', 'The same 8-part limit applies. An NVIDIA graphics card or a Mac is required.'], + zh: ['接续生成最多使用 3 张参考图,不使用首帧和尾帧。', '剪辑视图只能添加图片;参考视频和音频在创作面板的“素材”中添加。', '段数限制相同;需要 NVIDIA 显卡或 Mac。'], + en: ['A part that continues the previous one uses up to 3 reference images and no first or last frames.', 'Edit takes images only. Add reference videos and audio under "Media" in the creation panel.', 'The same part limits apply. An NVIDIA graphics card or a Mac is required.'], }, }, ]; From 4682ec3149aad174134f22934bcc87710045c373 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 26 Oct 2026 21:07:23 +0000 Subject: [PATCH 08/52] Editor: one wording for continued-part limits, and say the lengthened length - The shortest continued part reads the same everywhere: "Each continued part must be at least 5 seconds." - Dragging a continued part longer says its new length in tenths of a second, and adds the shortest-part rule when it was raised to that. - The parts limit with short parts names one continuous shot, in the editor, the planner, the chain runner and the Agent errors alike. --- freevideo_engine/agent/errors.py | 2 +- freevideo_engine/chain_plan.py | 4 ++-- freevideo_engine/chain_runner.py | 6 +++--- web/editor.js | 10 +++++----- web/editor_tracks.js | 8 +++++--- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 32303046..59974fad 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -67,7 +67,7 @@ 'chain_missing': (409, True, 'The saved chain receipt is unavailable.', 'Restore the chain receipt and retry resume, or submit a new job.'), 'join_failed': (409, True, 'The generated blocks could not be joined; completed blocks are retained.', 'POST /freevideo/agent/v1/jobs/{id}/resume to retry joining the completed blocks.'), 'chain_too_long': (422, False, 'One continuous shot can have up to 8 parts. Make it shorter.', 'Submit a new job with a smaller seconds, or cut to a new shot with a new job without continue_from; continue_from counts the parts of the video it continues.'), - 'chain_short_parts': (422, False, 'With a part shorter than 10 seconds, this video can have up to 4 parts. Make each part at least 10 seconds long, or use fewer parts.', 'Submit a new job whose continuing parts are at least 10 seconds, with fewer parts, or with first_frame_reference true; continue_from counts the parts of the video it continues.'), + 'chain_short_parts': (422, False, 'With a part shorter than 10 seconds, one continuous shot can have up to 4 parts. Make every part at least 10 seconds long, or use fewer parts.', 'Submit a new job whose continuing parts are at least 10 seconds, with fewer parts, or with first_frame_reference true; continue_from counts the parts of the video it continues.'), 'chain_rejected': (409, False, 'The continuous backend rejected this generation.', 'Read GET /freevideo/agent/v1/capabilities and submit a supported generation request.'), 'invalid_wait': (422, False, 'wait must be a finite nonnegative number of seconds.', 'Use wait=0 or wait=25; larger valid values are clamped to 25 seconds.'), 'invalid_resume': (422, False, 'resume accepts an empty object or an optional ref only.', 'Send {} or {"ref":"resume-1"}; submit a new job to change generation parameters.'), diff --git a/freevideo_engine/chain_plan.py b/freevideo_engine/chain_plan.py index 13289ecf..dfbc0281 100644 --- a/freevideo_engine/chain_plan.py +++ b/freevideo_engine/chain_plan.py @@ -162,8 +162,8 @@ def plan(seconds, single=False, max_frames=None, single_limit_seconds=None, bloc short = any(length < FULL_PART_SECONDS for length in lengths[0 if continued else 1:]) allowed = parts_limit(short, first_frame, max_parts) if parts > allowed and allowed < max_parts: - raise PlanError('too_many_short_parts', 'With a part shorter than %d seconds, this video can have up to ' - '%d parts. Make each part at least %d seconds long, or use fewer parts.' + raise PlanError('too_many_short_parts', 'With a part shorter than %d seconds, one continuous shot can have up to ' + '%d parts. Make every part at least %d seconds long, or use fewer parts.' % (FULL_PART_SECONDS, allowed, FULL_PART_SECONDS)) if parts > allowed: raise PlanError('too_many_parts', 'One continuous shot can have up to %d parts. Make it shorter.' % max_parts) diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index 485078b1..ad3ce625 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -33,10 +33,10 @@ class ChainShortPartsTooMany(ChainTooLong): def __init__(self, parts, limit): full = chain_plan.FULL_PART_SECONDS - ValueError.__init__(self, 'With a part shorter than %d seconds, this video can have up to %d parts. ' - 'Make each part at least %d seconds long, or use fewer parts.' % (full, limit, full)) + ValueError.__init__(self, 'With a part shorter than %d seconds, one continuous shot can have up to %d parts. ' + 'Make every part at least %d seconds long, or use fewer parts.' % (full, limit, full)) self.parts, self.limit = parts, limit - self.message_zh = '有短于 %d 秒的段时,这条视频最多 %d 段。请把每段加长到至少 %d 秒,或减少段数。' % (full, limit, full) + self.message_zh = '有短于 %d 秒的段时,一个连续镜头最多 %d 段。请把每段加长到至少 %d 秒,或减少段数。' % (full, limit, full) class ChainPartTooShort(ValueError): diff --git a/web/editor.js b/web/editor.js index 9b345a1f..bac41e14 100644 --- a/web/editor.js +++ b/web/editor.js @@ -48,8 +48,8 @@ export const PARTS_LIMIT_CODES = ['chain_too_long', 'chain_part_too_short', 'cha export function partsLimitText(code, t = en => en, limit = null) { const {followSeconds: least, fullSeconds: full, maxParts, shortMaxParts} = model.RULES; if (code === 'chain_part_too_short') return t(`Each continued part must be at least ${least} seconds. Make it longer.`, `接着生成的每一段至少 ${least} 秒,请加长这一段。`); - if (code === 'chain_short_parts') { const most = limit ?? shortMaxParts; return t(`With a part shorter than ${full} seconds, this video can have up to ${most} parts. Make each part at least ${full} seconds long, or use fewer parts.`, - `有短于 ${full} 秒的段时,这条视频最多 ${most} 段。请把每段加长到至少 ${full} 秒,或减少段数。`); } + if (code === 'chain_short_parts') { const most = limit ?? shortMaxParts; return t(`With a part shorter than ${full} seconds, one continuous shot can have up to ${most} parts. Make every part at least ${full} seconds long, or use fewer parts.`, + `有短于 ${full} 秒的段时,一个连续镜头最多 ${most} 段。请把每段加长到至少 ${full} 秒,或减少段数。`); } return t(`One continuous shot can have up to ${limit ?? maxParts} parts. Make it shorter.`, `一个连续镜头最多 ${limit ?? maxParts} 段,请缩短。`); } // Shared by error() and actions.reasonText: one sentence naming the failed step. @@ -70,9 +70,9 @@ export function editorErrorText(value, t = en => en) { if (code === 'gpu_memory' || /out of memory|cuda.*memory|显存不足|显存不够/i.test(raw)) return t(`${stage}: not enough GPU memory.`, `${stage}时显存不足。`); const known = { - chain_part_too_short: t(`Each continued part needs at least ${model.RULES.followSeconds} seconds.`, `接续的每段至少 ${model.RULES.followSeconds} 秒。`), - chain_short_parts: t(`With a part shorter than ${model.RULES.fullSeconds} seconds, one video can have up to ${model.RULES.shortMaxParts} parts.`, - `有短于 ${model.RULES.fullSeconds} 秒的段时,一条视频最多 ${model.RULES.shortMaxParts} 段。`), + chain_part_too_short: t(`Each continued part must be at least ${model.RULES.followSeconds} seconds.`, `接着生成的每一段至少 ${model.RULES.followSeconds} 秒。`), + chain_short_parts: t(`With a part shorter than ${model.RULES.fullSeconds} seconds, one continuous shot can have up to ${model.RULES.shortMaxParts} parts.`, + `有短于 ${model.RULES.fullSeconds} 秒的段时,一个连续镜头最多 ${model.RULES.shortMaxParts} 段。`), chain_too_long: t(`One continuous shot can have up to ${model.RULES.maxParts} parts.`, `一个连续镜头最多 ${model.RULES.maxParts} 段。`), too_long: t('This part is longer than the maximum length. Shorten it and try again.', '这一段超过了最长时长,请缩短后再试。'), missing_node: t('Generation is not ready. Restart FreeVideo.', '生成功能没有就绪,请重新启动 FreeVideo。'), diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 0006e5db..703bb896 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -10,6 +10,7 @@ import {createSelectionGestures, isMultiple, isTyping} from './editor_select.js' import {createTrackMenu} from './editor_menu.js'; import {transportSnapPoints, setSnapping as setTransportSnapping} from './editor_transport_model.js'; import {attachAudioTracks} from './editor_audio.js'; +import {seconds as secondsText} from './editor_audio_notes.js'; import {renderSpeedBadge} from './editor_speed.js'; import {speedEdited} from './editor_speed_model.js'; @@ -960,9 +961,10 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = else if (result.kind === 'extend') { const frames = result.frame - finished.clip.start - finished.clip.frames + result.frames; if (extendClip(finished.id, frames) && result.raised) { - const least = model.RULES.followSeconds; - actions.notify?.(result.least ? t(`Each continued part needs at least ${least} seconds.`, `接着生成的每一段至少 ${least} 秒。`) - : t(`Extended to ${result.frames / model.FPS} s.`, `已补到 ${result.frames / model.FPS} 秒。`)); + // Say the new length (tenths, rounded down like other durations) so the change is visible. + const least = model.RULES.followSeconds, length = secondsText(result.frames, model.FPS); + actions.notify?.(result.least ? t(`Lengthened to ${length} s. Each continued part must be at least ${least} seconds.`, `已加长到 ${length} 秒。接着生成的每一段至少 ${least} 秒。`) + : t(`Lengthened to ${length} s.`, `已加长到 ${length} 秒。`)); } } else attempt(t('Trim', '裁短'), record => model.trim(record, finished.id, finished.edge, result.frame)); renderSelection(); From b13c9d71b1d1b04ffde58be5fa3acac3070c64bd Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 26 Oct 2026 17:33:57 +0000 Subject: [PATCH 09/52] Judge pinned dependency checkouts by content and replace stale ones safely Dependency checkouts are now compared by file content only. Every Git check runs with core.filemode and core.autocrlf turned off for that command, so a copy, restore or drive that changes permission bits, or a global line-ending setting, no longer makes a pinned checkout look edited. With a sparse checkout, the status check covers only the sparse paths, so a file that appears outside them is ignored. The deep-path Windows branch uses the same settings. Git now reads the repository in the dependency folder itself: discovery stops at its parent folder and inherited GIT_DIR-style variables are dropped, so a folder that lost .git/refs no longer reports the HEAD of an enclosing repository. Reading HEAD from the file follows a symbolic reference to its commit, through the loose ref or packed-refs. When a dependency folder that FreeVideo created in its vendor folder (same sparse patterns, or no or the same origin) holds a different version or changed content, the fresh copy is cloned and verified first. The old folder is then renamed to .replaced-