From e55235e67b5ac234c78952056f43a86075c08c61 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 16:55:08 +0000 Subject: [PATCH 01/21] Install progress: the GPU acceleration build keeps the step's sentence; a blank line waits to show --- freevideo_engine/launcher_session.py | 14 ++++++- freevideo_engine/setup_progress.py | 59 +++++++++++++++++++++++++++- freevideo_engine/terminal_ui.py | 24 +++++------ 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 378778c5..3856bf52 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -21,7 +21,7 @@ from .model_status import FAMILIES, NAMES from .model_guidance import (package_instructions, video_instructions, runtime_packages_supported, download_view, export_download_list, DownloadListError, DOWNLOAD_COPY) -from .setup_progress import ModelLine, progress_text +from .setup_progress import LineHold, ModelLine, progress_text from .terminal_ui import clean, duration from .model_upgrade import TARGET from .runtime_alias import checked_real_path @@ -2141,6 +2141,16 @@ def snapshot(self): imported = self.importer.state file_text = progress_text(dict(activity=imported['activity'], unit='bytes', name=display('Package files', zh), done=imported.get('activity_done'), total=imported.get('activity_total')), zh) + if not hasattr(self, 'line_hold'): + self.line_hold = LineHold() + detail = clean(progress.get('detail', '')) + urgent = (progress.get('notice') or progress.get('activity') == 'resume' + or progress.get('state') in ('failed', 'cancelled') or steady == display('Connecting to download server', zh)) + held = self.line_hold.text(file_text or detail, immediate=bool(urgent), zh=zh) + if held != (file_text or detail): + file_text, detail = held, '' + else: + detail = '' if file_text else detail # The time row's estimate: only while downloading, from what is left at the current speed. from .setup_progress import number left, rate = (current.get('total') or 0) - (current.get('done') or 0), current.get('rate') @@ -2247,7 +2257,7 @@ def snapshot(self): # Qt reads a list as an array; LEVELS is a tuple. compatibility=dict(self.compatibility, levels=list(self.compatibility.get('levels') or ())), report=dict(self.report), - models=models, overall=overall, progress=progress, detail='' if file_text else clean(progress.get('detail', '')), + models=models, overall=overall, progress=progress, detail=detail, progress_text=file_text, remaining_seconds=remaining, # The elapsed row is only shown while working. An idle clock would # keep publishing changed snapshots after a launch has failed. diff --git a/freevideo_engine/setup_progress.py b/freevideo_engine/setup_progress.py index a882d203..3907b94a 100644 --- a/freevideo_engine/setup_progress.py +++ b/freevideo_engine/setup_progress.py @@ -18,6 +18,9 @@ def number(value): COMPONENT_NAMES = ('Video ', 'Text ', 'Sampling ', 'Image ', 'Model', 'Runtime components') +RUNTIME_NAME = 'Runtime components' + + class ProgressState: def __init__(self): self.phase = {} @@ -25,6 +28,8 @@ def __init__(self): self.last = {} self.model_groups = [] self.download_notices = {} + self.runtime_parts = {} + self.runtime_total = self.runtime_done = 0 def update(self, event, now=None): now = time.monotonic() if now is None else now @@ -52,6 +57,13 @@ def update(self, event, now=None): state = event.get('state', 'running') self.tasks[key] = dict(previous, **event, observed_at=now) self.tasks[key]['state'] = state + row = self.tasks[key] + if row.get('name') == RUNTIME_NAME and row.get('unit') == 'bytes' and number(row.get('total')) and row['total'] > 0: + self.runtime_parts[row.get('scope') or key] = (min(row.get('done') or 0, row['total']), row['total']) + # Wheels download side by side, each with its own size; the group + # keeps one total that only grows, as a model group does. + self.runtime_done = max(self.runtime_done, sum(d for d, _ in self.runtime_parts.values())) + self.runtime_total = max(self.runtime_total, sum(t for _, t in self.runtime_parts.values())) self.last = self.tasks[key] # Completed events need not grow with the number of downloaded files. for name in list(self.tasks): @@ -71,12 +83,16 @@ def models(row): active.sort(key=lambda row: (models(row), number(row.get('total')) and row['total'] > 0, row.get('unit') == 'bytes'), reverse=True) current = active[0] if active else self._view(self.last, now) + if (current.get('name') == RUNTIME_NAME and current.get('activity') == 'download' + and self.runtime_total > 0): + current = dict(current, unit='bytes', total=self.runtime_total, done=min(self.runtime_done, self.runtime_total), + fraction=min(self.runtime_done, self.runtime_total) / self.runtime_total) if self.download_notices: notice = next(reversed(self.download_notices.values())) # A temporary activity line, with the underlying stage and counters # retained so clearing it restores the current operation immediately. current = dict(current, label=current.get('label') or notice['label'], - detail=notice['detail'], activity='', name='') + detail=notice['detail'], activity='', name='', notice=True) return dict(phase_progress=dict(self.phase), active_tasks=active, progress=current, model_groups=self.model_groups) @staticmethod @@ -131,7 +147,9 @@ def progress_text(event, zh=False, *, decimal_sizes=False): # MiB rather than tenths of GiB: even slow disks change the visible text. parts = [t(verb[0], verb[1] + gap) + name] done, total = event.get('done'), event.get('total') - if event.get('unit') == 'bytes' and number(done) and number(total) and total >= 2**20 and done <= total: + # Sampling caches are checked file by file; the changing count would only flicker. + if (event.get('unit') == 'bytes' and number(done) and number(total) and total >= 2**20 and done <= total + and name.lower() not in ('sampling caches', '采样缓存')): parts.append('%d / %d %s' % (done // 2**20, total // 2**20, 'MiB')) return ' · '.join(parts) if activity == 'resume': @@ -160,6 +178,43 @@ def progress_text(event, zh=False, *, decimal_sizes=False): return '' +class LineHold: + """A changed line stays three seconds before the latest one replaces it; the lines in between are skipped. + Retry, resume, stall and error lines are shown at once and do not start a hold, a language + switch drops the held line, and a line that ends stays until a new one replaces it or three + seconds pass without one.""" + HOLD_SECONDS = 3. + + def __init__(self): + self.shown = self.shown_at = self.zh = self.blank_since = None + + def text(self, text, now=None, immediate=False, zh=False): + now = time.monotonic() if now is None else now + if zh != self.zh: + self.shown = self.shown_at = None + self.zh = zh + if immediate: + self.shown = self.shown_at = self.blank_since = None + return text + if not text: + # A blank is a change too: the line stays, a new one replaces it at once, + # and the blank shows only when no new line came within three seconds. + if self.shown is None: + return text + self.blank_since = now if self.blank_since is None else self.blank_since + if now - self.blank_since < self.HOLD_SECONDS or now - self.shown_at < self.HOLD_SECONDS: + return self.shown + self.shown = self.shown_at = self.blank_since = None + return text + self.blank_since = None + if text == self.shown: + return text + if self.shown is not None and now - self.shown_at < self.HOLD_SECONDS: + return self.shown + self.shown, self.shown_at = text, now + return text + + class ModelLine: """One steady line while a model group downloads: its shards, connections and checks must not take turns on the screen.""" diff --git a/freevideo_engine/terminal_ui.py b/freevideo_engine/terminal_ui.py index c764a3ac..e344a55b 100644 --- a/freevideo_engine/terminal_ui.py +++ b/freevideo_engine/terminal_ui.py @@ -329,7 +329,9 @@ def __init__(self, path): self.parallel_tasks = {} self.model_groups = None # Ninja runs several times during one build, each with its own count. - self.compile_before, self.compile_last, self.compile_runs = 0, (0, 0), 0 + self.compile_last, self.compile_runs = (0, 0), 0 + + COMPILE_LINE = 'Compile acceleration for your GPU · the first build takes longer' def compile_fraction(self): """How far the compile has advanced, never reaching 1; None before any step. @@ -441,18 +443,18 @@ def update(**values): if package_phase[0] == 'compile': done, total = int(match[1]), int(match[2]) if total != self.compile_last[1] or done < self.compile_last[0]: - self.compile_before += self.compile_last[1] self.compile_runs += self.compile_last[1] > 0 self.compile_last = (done, total) - shown_done, shown_total = int(match[1]), int(match[2]) if package_phase[0] == 'compile': - # One count across the runs, so it never restarts from 1. - shown_done += self.compile_before - shown_total += self.compile_before - update(done=shown_done, total=shown_total, - activity='count', name=package_phase[1], - detail='Installing packages' if self.installing_packages else 'Compiling CUDA kernels', - scope='package-' + package_phase[0], unit='items') + # The runs have different sizes and their number is unknown, so a count + # would grow in jumps; the line keeps the step's own sentence, which says + # the wait is expected, and the bar keeps the progress. + update(done=0, total=0, activity='note', name=self.COMPILE_LINE, + detail='Compiling CUDA kernels', scope='package-compile', unit='items') + else: + update(done=int(match[1]), total=int(match[2]), activity='count', name=package_phase[1], + detail='Installing packages' if self.installing_packages else 'Compiling CUDA kernels', + scope='package-' + package_phase[0], unit='items') try: event = json.loads(line) except (ValueError, TypeError): @@ -612,7 +614,7 @@ def update(**values): speed = event.get('network_bytes_per_second') shown_speed = speed if speed is not None else event['bytes_per_second'] update(done=done, total=total, scope=description + self.model_source, rate=event['bytes_per_second'], unit='bytes', - activity='download', name=event.get('name') or ('Python' if description.startswith('cpython-') else 'Runtime components' if description == 'torch' + activity='download', name=event.get('name') or ('Python' if description.startswith('cpython-') else 'Runtime components' if description == 'torch' or event.get('counter_source') == 'uv-terminal' else model_download_name(description).split(' · ')[0]), detail='%s%s%.1f MiB/s · %.1f / %.1f MiB · %s' % (source + ' · ' if source else '', From 55751aa79dde3e45549da73a1d60f2fe5d7974b2 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 16:55:08 +0000 Subject: [PATCH 02/21] Setup calls the GPU acceleration steps by one name in Chinese --- freevideo_engine/launcher_copy.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/launcher_copy.py b/freevideo_engine/launcher_copy.py index d4eade84..b47e7fb2 100644 --- a/freevideo_engine/launcher_copy.py +++ b/freevideo_engine/launcher_copy.py @@ -102,8 +102,8 @@ ('Verify the compiler before building GPU acceleration', '检查编译工具'), ('Compile acceleration for your GPU', '准备显卡加速'), ('the first build takes longer', '首次准备需要一些时间'), - ('Install the prepared GPU acceleration package', '安装 GPU 加速组件'), - ('Install the verified Windows acceleration package', '安装 Windows 加速组件'), + ('Install the prepared GPU acceleration package', '安装显卡加速组件'), + ('Install the verified Windows acceleration package', '安装显卡加速组件'), ('Verify CUDA and the Windows runtime libraries', '检查运行组件'), ('Prepare verified source files', '准备组件文件'), ('Download verified source code', '下载组件文件'), ('Prepare source files for installation', '准备安装文件'), @@ -111,7 +111,7 @@ ('Dependencies resolved', '组件检查完成'), ('preparing downloads', '准备下载'), ('Downloads ready', '下载完成'), ('Downloading packages', '下载组件'), ('installing packages', '安装组件'), ('Packages installed', '组件已安装'), ('finishing checks', '完成检查'), - ('Installing packages', '安装组件'), ('Compiling CUDA kernels', '编译 GPU 加速组件'), + ('Installing packages', '安装组件'), ('Compiling CUDA kernels', '准备显卡加速'), ('Load cached blocks and upload resident weights', '加载模型'), ('Finish model placement', '完成模型加载'), ('Verify cached weights', '校验模型缓存'), ('Read and convert source weights', '正在准备模型'), ('Storage ready', '模型文件已就绪'), ('Download', '下载'), ('Model', '模型'), From a13e32ea0d27f6c2f5201c61eae0502f1732cff7 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 16:55:09 +0000 Subject: [PATCH 03/21] Prompt enhancement model also downloads from ModelScope --- freevideo_engine/prompt_vlm/assets.py | 38 ++++++++++++++++++++------ freevideo_engine/prompt_vlm/model.json | 1 + 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/freevideo_engine/prompt_vlm/assets.py b/freevideo_engine/prompt_vlm/assets.py index 1112fdd0..408906d0 100644 --- a/freevideo_engine/prompt_vlm/assets.py +++ b/freevideo_engine/prompt_vlm/assets.py @@ -4,6 +4,8 @@ import shutil from .. import disk_space +FAMILY = 'image-models' + def catalog(): return json.loads(Path(__file__).with_name('model.json').read_text(encoding='utf-8')) @@ -26,6 +28,23 @@ def ready(root): return False +def remote(spec, row): + """One catalog file with every source FreeVideo may fetch it from. + + Hugging Face hosts are pinned to the catalog revision. ModelScope has no + matching commit, so its copy is accepted only when the size and SHA-256 in + the catalog match, as for the image model. + """ + from ..network import source_url + hf = '/%s/resolve/%s/%s' % (spec['repo'], spec['revision'], row['file']) + mirror = spec['modelscope'] + return dict(repo=spec['repo'], revision=spec['revision'], file=row['file'], family='image', sources=[ + dict(id='official', url=source_url('models', 'official') + hf), + dict(id='hf-mirror', url=source_url('models', 'hf-mirror') + hf), + dict(id='modelscope', url=source_url(FAMILY, 'modelscope') + '/models/%s/resolve/%s/%s' % ( + mirror['repo'], mirror['revision'], row['file']))]) + + def install(root, progress, check): from .. import network, provision from ..adaln_assets import _download_plan @@ -52,22 +71,23 @@ def install(root, progress, check): if disk_space.free_bytes(folder) < total - retained + 256 * 2**20: raise ValueError('disk_space') plan = _download_plan(root) - # This catalog has no verified ModelScope mirror yet. Preserve configured - # routing, but never guess a mirror revision or send credentials to it. - plan['sources'] = dict(plan.get('sources', {}), models=[ - row for row in plan.get('sources', {}).get('models', []) if row['id'] in ('official', 'hf-mirror')]) - if not plan['sources']['models']: - plan['sources']['models'] = [{'id': 'official'}, {'id': 'hf-mirror'}] + # Same three hosts as the image model, so the same ordering, measured + # routes and Download source choice apply. Installs whose plan predates + # that family keep their Hugging Face order, with ModelScope after it. + sources = plan['sources'] = dict(plan.get('sources', {})) + if not sources.get(FAMILY): + sources[FAMILY] = [dict(row) for row in sources.get('models', []) + if row['id'] in ('official', 'hf-mirror')] or [{'id': 'official'}, {'id': 'hf-mirror'}] + sources[FAMILY].append({'id': 'modelscope'}) plan.update(quiet=True, resource_check=check) done = 0 for row in missing: check() path = folder / row['file'] - remote = dict(repo=spec['repo'], revision=spec['revision'], file=row['file']) def moved(current, size, speed, **kwargs): progress(dict(phase='download', done=done + current, total=total)) - network.download(network.model_urls(plan, remote), path, row['sha256'], moved, - network=plan, size=row['bytes'], keep_partial=True, stall_seconds=30, + network.download(network.model_urls(plan, remote(spec, row)), path, row['sha256'], moved, + network=plan, size=row['bytes'], category=FAMILY, keep_partial=True, stall_seconds=30, slow_seconds=15, low_speed_limit=64 * 1024) ledger[str(path)] = provision.file_identity(path, row) save(ledger_path, ledger) diff --git a/freevideo_engine/prompt_vlm/model.json b/freevideo_engine/prompt_vlm/model.json index 8e599807..e9183dd0 100644 --- a/freevideo_engine/prompt_vlm/model.json +++ b/freevideo_engine/prompt_vlm/model.json @@ -2,6 +2,7 @@ "repo": "Qwen/Qwen3-VL-4B-Instruct", "revision": "ebb281ec70b05090aa6165b016eac8ec08e71b17", "license": "Apache-2.0", + "modelscope": {"repo": "Qwen/Qwen3-VL-4B-Instruct", "revision": "master"}, "files": [ { "file": "chat_template.json", From 92dfb3c1b2171d29be753fea5f1ff104082feb6d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 16:55:09 +0000 Subject: [PATCH 04/21] Mac docs: timings are for the Light level; drop the High-level time multiple --- docs/Mac.md | 2 +- docs/Mac.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Mac.md b/docs/Mac.md index b63cc83a..74aaef21 100644 --- a/docs/Mac.md +++ b/docs/Mac.md @@ -30,7 +30,7 @@ This approves FreeVideo only. It doesn't turn off Gatekeeper or change your othe ## Generation time -On an M5 with 24 GB of unified memory and about 14 GB available: +On an M5 with 24 GB of unified memory and about 14 GB available, at the Light level: | Resolution | Length | Time | | --- | --- | --- | diff --git a/docs/Mac.zh-CN.md b/docs/Mac.zh-CN.md index eb406e06..ee11f2bb 100644 --- a/docs/Mac.zh-CN.md +++ b/docs/Mac.zh-CN.md @@ -30,7 +30,7 @@ Mac 预览版暂未进行 Apple 公证,因此首次打开时 macOS 会拦截 ## 生成时间 -在 M5、24GB 统一内存、可用内存约 14GB 的情况下: +在 M5、24GB 统一内存、可用内存约 14GB、轻量档的情况下: | 分辨率 | 时长 | 生成时间 | | --- | --- | --- | From 557cd837f73cb1d0ab356d2dc76970d49560db62 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 16:55:10 +0000 Subject: [PATCH 05/21] Agent sequences are saved as library works and keep their name in the manifest --- freevideo_engine/agent/jobs.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 56ec0f90..6d25a5a4 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -1953,13 +1953,10 @@ def run_assemble(self, row): clips = [] for item in spec['clips']: clips.append(dict(path=str(self.source_video(item)), start=item.get('start', 0.), end=item.get('end'))) - name = spec.get('name') or spec.get('label') or row['id'] - name = ''.join(c if c.isalnum() or c in '._-' else '-' for c in name)[:64].strip('-.') or row['id'] - relative = 'FreeVideo-Sequences/%s/%s-%s.mp4' % (time.strftime('%Y-%m-%d', time.gmtime()), name, row['id']) - if row.get('parent', {}).get('production'): - # Deterministic output identity survives a process restart during assembly. - run = hashlib.sha256(row['id'].encode()).hexdigest()[:32] - relative = 'FreeVideo/%s/%s/video.mp4' % (iso(row['created_at'])[:10], run) + # Every sequence is saved as a library work, so it shows in My works and has a library link. + # The identity comes from the job id, so it survives a process restart during assembly. + run = hashlib.sha256(row['id'].encode()).hexdigest()[:32] + relative = 'FreeVideo/%s/%s/video.mp4' % (iso(row['created_at'])[:10], run) out = self.port.output_path(relative, create=True) row['_phase'] = 'assembling' def progress(value): @@ -1971,20 +1968,16 @@ def progress(value): cancelled=lambda: bool(row.get('_cancel'))) if row.get('_cancel'): raise JobError('cancelled', 'Cancelled') - manifest = dict(schema='freevideo.sequence', schema_version=1, job=row['id'], label=spec.get('label'), + manifest = dict(schema='freevideo.sequence', schema_version=1, job=row['id'], label=spec.get('label'), name=spec.get('name'), project=spec.get('project'), created_at=iso(self.clock()), clips=[dict(source={k: v for k, v in item.items() if k in ('from_job', 'library', 'file')}, start=item.get('start', 0.), end=item.get('end')) for item in spec['clips']], transition=spec['transition'], transition_seconds=spec['transition_seconds']) out.with_suffix('.json').write_text(json.dumps(manifest, indent=1), encoding='utf-8') - if row.get('parent', {}).get('production'): - out.with_suffix('.request.json').write_text(json.dumps(dict(success=True, geometry={ - k: made.get(k) for k in ('seconds', 'frames', 'width', 'height')}, sequence=manifest)), encoding='utf-8') - return dict(library_id=library_id(relative), video=library_view(self.port, relative), - **{k: made.get(k) for k in ('seconds', 'frames', 'width', 'height', 'has_audio', 'clips')}) - return dict(video=self.port.file_view(relative), seconds=made.get('seconds'), frames=made.get('frames'), - width=made.get('width'), height=made.get('height'), has_audio=made.get('has_audio'), - clips=manifest['clips']) + out.with_suffix('.request.json').write_text(json.dumps(dict(success=True, geometry={ + k: made.get(k) for k in ('seconds', 'frames', 'width', 'height')}, sequence=manifest)), encoding='utf-8') + return dict(library_id=library_id(relative), video=library_view(self.port, relative), + **{k: made.get(k) for k in ('seconds', 'frames', 'width', 'height', 'has_audio', 'clips')}) VLM_ERRORS = { From 67b54a20ab1d64944ef4b72d58b5152ece06e4fa Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 16:58:00 +0000 Subject: [PATCH 06/21] Installers: prune Qt bundle payloads, dedupe data and recompress for Windows and Linux --- MANIFEST.in | 2 + scripts/build_linux.py | 10 ++- scripts/build_windows.py | 48 +++++++++++++- scripts/deduplicate_bundle_data.py | 66 +++++++++++++++++++ scripts/prune_qt_bundle.py | 102 +++++++++++++++++++++++++++++ scripts/pyi_rth_bundle_data.py | 27 ++++++++ scripts/verify_dmg_conversion.py | 68 +++++++++++++++++++ 7 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 scripts/deduplicate_bundle_data.py create mode 100644 scripts/prune_qt_bundle.py create mode 100644 scripts/pyi_rth_bundle_data.py create mode 100644 scripts/verify_dmg_conversion.py diff --git a/MANIFEST.in b/MANIFEST.in index 4ff04680..2309e5f5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -16,6 +16,8 @@ include scripts/bootstrap_linux.sh scripts/build_windows.py scripts/windows_app. include scripts/build_macos.py scripts/macos_app.py scripts/verify_macos_build.py include scripts/scan_windows_release.py include scripts/build_launcher_notices.py +include scripts/prune_qt_bundle.py scripts/deduplicate_bundle_data.py scripts/pyi_rth_bundle_data.py +include scripts/verify_dmg_conversion.py prune docs include docs/LoRA.md docs/LoRA.zh-CN.md prune tests diff --git a/scripts/build_linux.py b/scripts/build_linux.py index b1988c28..5d4e4ba3 100644 --- a/scripts/build_linux.py +++ b/scripts/build_linux.py @@ -27,6 +27,8 @@ from freevideo_engine.release_notes import markdown from scripts.build_launcher_notices import collect as collect_notices from scripts.build_windows import build_info, bundle_data_args, stamp_source +from scripts.prune_qt_bundle import prune_qt_bundle, write_prune_report +from scripts.deduplicate_bundle_data import deduplicate_bundle_data, write_data_report APP_ID = 'org.flashml.FreeVideo' NAME = RELEASE_ASSETS[LINUX_CHANNEL][0] @@ -224,13 +226,19 @@ def main(): str(ROOT / 'scripts/linux_app.py')] subprocess.run(command, cwd=ROOT, check=True) dist = out / 'dist' / 'FreeVideo' + write_prune_report(out, prune_qt_bundle(dist)) bundled = bundle_libraries(dist / '_internal', args.libraries.resolve() if args.libraries else None) folder = appdir(out, dist, ROOT / 'freevideo_engine/assets/icon.png') + # copytree expands hardlinks: deduplicate the final AppDir, after copying. + write_data_report(out, deduplicate_bundle_data(folder)) release = out / 'release' release.mkdir() executable = release / NAME env = dict(os.environ, ARCH='x86_64', APPIMAGE_EXTRACT_AND_RUN='1') - subprocess.run([str(appimagetool), '--no-appstream', '--runtime-file', str(runtime), str(folder), str(executable)], + subprocess.run([str(appimagetool), '--no-appstream', '--runtime-file', str(runtime), '--comp', 'zstd', + '--mksquashfs-opt=-b', '--mksquashfs-opt=1M', + '--mksquashfs-opt=-Xcompression-level', '--mksquashfs-opt=19', + str(folder), str(executable)], cwd=out, env=env, check=True) digest = hashlib.sha256(executable.read_bytes()).hexdigest() (release / 'SHA256SUMS.txt').write_text(digest + ' ' + NAME + '\n', encoding='utf-8') diff --git a/scripts/build_windows.py b/scripts/build_windows.py index 59e20edc..bfb5d6f7 100644 --- a/scripts/build_windows.py +++ b/scripts/build_windows.py @@ -16,6 +16,8 @@ from freevideo_engine.launcher_update import REPOSITORY, CHANNEL, TRACKS from freevideo_engine.release_notes import catalog, markdown from scripts.build_launcher_notices import collect as collect_notices +from scripts.prune_qt_bundle import prune_qt_bundle, write_prune_report +from scripts.deduplicate_bundle_data import deduplicate_bundle_data, write_data_report def release_track(): @@ -96,6 +98,50 @@ def version_file(out, identity): return path +def build_pyinstaller(command, out, *, onefolder): + # Windows onefile has no tree to prune after EXE: use exactly the same + # globs on Analysis TOCs before PyInstaller writes its compressed archive. + # Generate the usual spec so all bootloader/version/manifest defaults stay + # those of the pinned PyInstaller, then insert only our build-time filter. + options, i = [], 3 + while i < len(command): + option = command[i] + if option in ('--distpath', '--workpath'): + i += 2 + continue + if option != '--noconfirm': + options.append(option) + i += 1 + if not onefolder: + options += ['--runtime-hook', str(ROOT / 'scripts/pyi_rth_bundle_data.py')] + subprocess.run([sys.executable, '-m', 'PyInstaller.utils.cliutils.makespec', *options], cwd=ROOT, check=True) + spec = out / 'FreeVideo.spec' + if not onefolder: + injection = f''' +import sys +sys.path.insert(0, {str(ROOT)!r}) +from scripts.prune_qt_bundle import prune_qt_toc, write_prune_report +from scripts.deduplicate_bundle_data import deduplicate_toc, write_data_report +a.binaries, removed_binaries = prune_qt_toc(a.binaries) +a.datas, removed_data = prune_qt_toc(a.datas) +write_prune_report({str(out)!r}, dict(files=removed_binaries['files'] + removed_data['files'], + bytes=removed_binaries['bytes'] + removed_data['bytes'])) +a.datas, deduplicated = deduplicate_toc(a.datas, {str(out)!r}) +write_data_report({str(out)!r}, deduplicated) +''' + text = spec.read_text(encoding='utf-8') + if text.count('pyz = PYZ(') != 1: + raise ValueError('Unexpected PyInstaller spec layout') + spec.write_text(text.replace('pyz = PYZ(', injection + '\npyz = PYZ('), encoding='utf-8') + subprocess.run([sys.executable, '-m', 'PyInstaller', '--noconfirm', + '--distpath', str(out / 'dist'), '--workpath', str(out / 'build'), str(spec)], + cwd=ROOT, check=True) + if onefolder: + dist = out / 'dist/FreeVideo' + write_prune_report(out, prune_qt_bundle(dist)) + write_data_report(out, deduplicate_bundle_data(dist)) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--out', type=Path, required=True) @@ -142,7 +188,7 @@ def main(): '--exclude-module', 'torch', '--exclude-module', 'triton', '--exclude-module', 'numpy', '--exclude-module', 'transformers', '--exclude-module', 'tkinter', str(ROOT / 'scripts/windows_app.py')] - subprocess.run(command, cwd=ROOT, check=True) + build_pyinstaller(command, out, onefolder=args.onefolder) executable = out / 'dist' / ('FreeVideo/FreeVideo.exe' if args.onefolder else 'FreeVideo.exe') digest = hashlib.sha256(executable.read_bytes()).hexdigest() (executable.parent / 'SHA256SUMS.txt').write_text(digest + ' FreeVideo.exe\n', encoding='utf-8') diff --git a/scripts/deduplicate_bundle_data.py b/scripts/deduplicate_bundle_data.py new file mode 100644 index 00000000..84455223 --- /dev/null +++ b/scripts/deduplicate_bundle_data.py @@ -0,0 +1,66 @@ +"""Keep both frozen-resource and engine-source paths with one stored payload.""" +import json +import os +from pathlib import Path + +from scripts.prune_qt_bundle import checked_path + +LINK_MANIFEST = '_freevideo-data-links.json' + + +def deduplicate_bundle_data(dist, *, symbolic=False): + """Only replace byte-identical regular files; symlinks stay inside dist.""" + dist = Path(dist).resolve(strict=True) + rows = [] + for source_root in sorted(dist.rglob('engine-source')): + if not source_root.is_dir() or source_root.is_symlink(): + continue + internal = source_root.parent + for source in sorted(source_root.rglob('*')): + if source.is_symlink() or not source.is_file(): + continue + target = internal / source.relative_to(source_root) + if target.is_symlink() or not target.is_file(): + continue + checked_path(source, dist) + checked_path(target, dist) + if source.samefile(target) or source.read_bytes() != target.read_bytes(): + continue + size = target.stat().st_size + temporary = target.with_name(target.name + '.dedup-link') + if symbolic: + temporary.symlink_to(os.path.relpath(source, target.parent)) + else: + os.link(source, temporary) + temporary.replace(target) + rows.append(dict(path=target.relative_to(dist).as_posix(), + source=source.relative_to(dist).as_posix(), bytes=size)) + return dict(files=rows, bytes=sum(row['bytes'] for row in rows)) + + +def deduplicate_toc(entries, out): + """Onefile stores one copy; its packaging hook restores hardlink aliases. + + Do this only for a onefile extraction, which is owned and writable. An + installed onedir tree must be complete before a normal user launches it. + """ + sources = {name.replace('\\', '/'): source for name, source, kind in entries if kind == 'DATA'} + kept, rows = [], [] + for entry in entries: + name, source, kind = entry + name = name.replace('\\', '/') + canonical = 'engine-source/' + name + if (kind == 'DATA' and not name.startswith('engine-source/') and canonical in sources + and Path(source).read_bytes() == Path(sources[canonical]).read_bytes()): + rows.append(dict(path=name, source=canonical, bytes=Path(source).stat().st_size)) + else: + kept.append(entry) + manifest = Path(out) / LINK_MANIFEST + manifest.write_text(json.dumps(rows, indent=2) + '\n', encoding='utf-8') + kept.append((LINK_MANIFEST, str(manifest), 'DATA')) + return kept, dict(files=rows, bytes=sum(row['bytes'] for row in rows)) + + +def write_data_report(out, result): + (Path(out) / 'data-deduplicated.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') + print('Data dedup: %d files, %d bytes' % (len(result['files']), result['bytes']), flush=True) diff --git a/scripts/prune_qt_bundle.py b/scripts/prune_qt_bundle.py new file mode 100644 index 00000000..39dc71de --- /dev/null +++ b/scripts/prune_qt_bundle.py @@ -0,0 +1,102 @@ +"""Build-only Qt exclusions for the software-rendered, Basic-style launcher.""" +from fnmatch import fnmatchcase +import json +import os +from pathlib import Path, PurePosixPath +import shutil + + +UNUSED_STYLES = ('FluentWinUI3', 'Fusion', 'Imagine', 'Material', 'Universal', + 'Windows', 'macOS', 'iOS') +UNUSED_MODULES = ('QtQuick/Dialogs', 'QtWayland/Compositor', 'QtTest', 'Qt/labs', + 'QtQuick/Particles', 'QtQuick/VectorImage', 'QtQuick/Effects', + 'QtQuick/Timeline', 'QtQuick/Timeline/BlendTrees', 'QtQuick/Pdf', 'QtPdf', + 'QtQuick/LocalStorage', 'QtQml/XmlListModel', 'QtQuick/VirtualKeyboard') +UNUSED_LIBRARIES = ( + 'Labs*', 'QuickControls2FluentWinUI3StyleImpl', 'QuickControls2Fusion*', + 'QuickControls2Imagine*', 'QuickControls2Material*', 'QuickControls2Universal*', + 'QuickControls2WindowsStyleImpl', 'QuickControls2MacOSStyleImpl', 'QuickControls2IOSStyleImpl', + 'QuickDialogs2*', 'WaylandCompositor*', 'Test', 'QuickTest', 'QuickParticles', + 'QuickVectorImage*', 'QuickEffects', 'QuickTimeline*', 'Pdf*', 'Sql', + 'QmlLocalStorage', 'QmlXmlListModel', 'VirtualKeyboard*', +) +# Relative to PySide6, across Windows wheels, Unix wheels and macOS frameworks. +# Keep Controls/Basic, Controls/impl, Templates, QtQuick, Layouts, Widgets and +# Wayland *client* plugins. No broad "Qt*" or platform-plugin exclusions. +QT_PRUNE_GLOBS = ( + 'opengl32sw.dll', 'Qt/opengl32sw.dll', 'translations', 'Qt/translations', + *(f'{prefix}qml/QtQuick/Controls/{style}' for prefix in ('', 'Qt/') for style in UNUSED_STYLES), + *(f'{prefix}qml/{module}' for prefix in ('', 'Qt/') for module in UNUSED_MODULES), + *(f'{prefix}plugins/{plugin}' for prefix in ('', 'Qt/') + for plugin in ('qmltooling', 'platforms/qdirect2d.dll', 'sqldrivers', 'virtualkeyboard')), + *(f'Qt6{library}.dll' for library in UNUSED_LIBRARIES), + *(f'Qt/lib/libQt6{library}.so*' for library in UNUSED_LIBRARIES), + *(f'Qt/lib/Qt{library}.framework' for library in UNUSED_LIBRARIES), + *(f'Qt/lib/libQt6{library}*.dylib' for library in UNUSED_LIBRARIES), + 'QtTest*.pyd', 'QtTest*.so', 'QtSql*.pyd', 'QtSql*.so', + 'QtPdf*.pyd', 'QtPdf*.so', +) + + +def should_prune(name): + """The same matcher serves emitted bundles and PyInstaller onefile TOCs.""" + parts = PurePosixPath(str(name).replace('\\', '/')).parts + if 'PySide6' not in parts: + return False + relative = '/'.join(parts[parts.index('PySide6') + 1:]) + return any(fnmatchcase(relative, pattern) or fnmatchcase(relative, pattern + '/*') + for pattern in QT_PRUNE_GLOBS) + + +def checked_path(path, dist): + if not path.resolve().is_relative_to(dist) or path == dist: + raise ValueError('Bundle path escapes dist: ' + str(path)) + + +def prune_qt_bundle(dist): + """Remove only explicit Qt globs; return every removed entry and real bytes.""" + dist = Path(dist).resolve(strict=True) + selected, rows = [], [] + for folder, dirs, files in os.walk(dist, followlinks=False): + for name in sorted(dirs + files): + path = Path(folder) / name + if should_prune(path.relative_to(dist)): + checked_path(path, dist) + selected.append(path) + if name in dirs: + dirs.remove(name) + # Validate all descendants before making any change. Never follow directory links. + for path in selected: + entries = [path] + if path.is_dir() and not path.is_symlink(): + entries += list(path.rglob('*')) + for entry in entries: + checked_path(entry, dist) + if entry.is_file() or entry.is_symlink(): + rows.append(dict(path=entry.relative_to(dist).as_posix(), + bytes=0 if entry.is_symlink() else entry.stat().st_size)) + for path in selected: + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + return dict(files=sorted(rows, key=lambda row: row['path']), + bytes=sum(row['bytes'] for row in rows)) + + +def prune_qt_toc(entries): + """Onefile has no emitted tree: filter after Analysis, before its CArchive.""" + kept, rows = [], [] + for entry in entries: + name, source, kind = entry + if should_prune(name): + size = Path(source).stat().st_size if kind != 'SYMLINK' else 0 + rows.append(dict(path=name, bytes=size)) + else: + kept.append(entry) + return kept, dict(files=rows, bytes=sum(row['bytes'] for row in rows)) + + +def write_prune_report(out, result): + (Path(out) / 'qt-pruned.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') + print('Qt prune: %d files, %d bytes' % (len(result['files']), result['bytes']), flush=True) diff --git a/scripts/pyi_rth_bundle_data.py b/scripts/pyi_rth_bundle_data.py new file mode 100644 index 00000000..daf21f45 --- /dev/null +++ b/scripts/pyi_rth_bundle_data.py @@ -0,0 +1,27 @@ +"""Restore data aliases in an owned PyInstaller onefile extraction on Windows.""" + + +def restore_data_links(bundle): + import json + import os + from pathlib import Path + import shutil + + bundle = Path(bundle).resolve() + rows = json.loads((bundle / '_freevideo-data-links.json').read_text(encoding='utf-8')) + for row in rows: + source, target = bundle / row['source'], bundle / row['path'] + if not source.resolve().is_relative_to(bundle) or not target.resolve().is_relative_to(bundle): + raise ValueError('Data alias escapes the extracted bundle') + target.parent.mkdir(parents=True, exist_ok=True) + try: + os.link(source, target) + except OSError: + # FAT/exFAT or a policy forbidding hardlinks must still launch. The + # archive contains only one copy even if extraction needs a copy. + shutil.copyfile(source, target) + + +if __name__ == '__main__': + import sys + restore_data_links(sys._MEIPASS) diff --git a/scripts/verify_dmg_conversion.py b/scripts/verify_dmg_conversion.py new file mode 100644 index 00000000..e32cfddb --- /dev/null +++ b/scripts/verify_dmg_conversion.py @@ -0,0 +1,68 @@ +"""Verify both disk images, mounted signatures and every payload file after convert.""" +import argparse +import json +from pathlib import Path +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from scripts.verify_macos_build import snapshot + + +def verify_conversion(original, converted, out, *, command_runner=None): + """Mac commands are injectable so failure/cleanup checks run on Linux.""" + run = command_runner or subprocess.run + out = Path(out).resolve() + out.mkdir(parents=True, exist_ok=False) + mounted, commands, manifests = [], [], [] + result = dict(success=False) + + def command(args): + commands.append(args) + return run(args, check=True, capture_output=True, text=True, timeout=180) + + try: + for label, image in (('original', original), ('converted', converted)): + command(['/usr/bin/hdiutil', 'verify', str(image)]) + mount = out / (label + '.noindex') + mount.mkdir() + command(['/usr/bin/hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', + '-mountpoint', str(mount), str(image)]) + mounted.append(mount) + command(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(mount / 'FreeVideo.app')]) + manifest = snapshot(mount) + (out / (label + '-files.json')).write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') + manifests.append(manifest) + differences = [name for name in sorted(set(manifests[0]) | set(manifests[1])) + if manifests[0].get(name) != manifests[1].get(name)] + if differences: + result['differences'] = differences + raise ValueError('Converted DMG payload differs from original') + result.update(success=True, files=sum(row['kind'] == 'file' for row in manifests[0].values()), + images_verified=True, mounted_signatures_verified=True, payload_sha256_equal=True) + except Exception as error: + result['error'] = str(error) + finally: + for mount in reversed(mounted): + try: + command(['/usr/bin/hdiutil', 'detach', str(mount)]) + except Exception as error: + result.update(success=False, detach_error=str(error)) + result['commands'] = commands + (out / 'result.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + build = json.loads((args.build / 'build-result.json').read_text(encoding='utf-8')) + result = verify_conversion(build['original_dmg'], build['dmg'], args.out) + print(json.dumps({key: value for key, value in result.items() if key != 'commands'})) + raise SystemExit(not result['success']) + + +if __name__ == '__main__': + main() From f4ac944221bcc279d41a433e3b3123761ccf9f24 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 19:12:46 +0000 Subject: [PATCH 07/21] int8 LoRA: skip unsupported patches one by one and count them by category --- freevideo_engine/lora_cache.py | 20 ++++++++++++++++---- freevideo_engine/lora_online_cache.py | 15 ++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/freevideo_engine/lora_cache.py b/freevideo_engine/lora_cache.py index 93d7e528..4434a426 100644 --- a/freevideo_engine/lora_cache.py +++ b/freevideo_engine/lora_cache.py @@ -32,6 +32,10 @@ MODULATION = re.compile(r'transformer_blocks\.\d+\.adaln_proj\.') +INT8_UNMERGEABLE = ('This LoRA changes parts of the model that the int8 version cannot merge yet, ' + 'so it cannot be used for now. LoRAs that change only linear layers work.') + + def index_adapter(row, targets, skipped=None): pairs = {} full = [] @@ -76,8 +80,8 @@ def index_adapter(row, targets, skipped=None): for target, split, swap in _targets(name): if target not in targets and '.attn.' in target: target = target.replace('.attn.', '.attn.orig.', 1) - if skipped is not None and target not in targets and MODULATION.match(target): - skipped.append(target) + if skipped is not None and target not in targets: + skipped.append(('modulation' if MODULATION.match(target) else 'other_layer', target)) continue expected = targets.get(target) rows = bshape[0] if split is None else bshape[0] // 3 @@ -92,6 +96,15 @@ def index_adapter(row, targets, skipped=None): target += '#bias' if bias else '' if target not in targets and '.attn.' in target: target = target.replace('.attn.', '.attn.orig.', 1) + if skipped is not None: + # Quantized weights cannot take a full difference, and the int8 package + # keeps no bias or non-linear patch path yet: count each one, apply the rest. + if target.endswith('#bias'): + skipped.append(('bias', target)) + else: + skipped.append(('quantized_diff' if target in targets or + target.replace('.attn.', '.attn.orig.', 1) in targets else 'other_layer', target)) + continue expected = targets.get(target) actual = list(shape) if split is not None: @@ -163,8 +176,7 @@ def prepare(cache, adapters, output_root=None): manifest_path = cache / 'manifest.json' manifest = json.loads(manifest_path.read_text(encoding='utf-8')) if manifest.get('precision') == 'int8': - raise ValueError('This LoRA changes parts of the model that the int8 version cannot merge yet, ' - 'so it cannot be used for now. LoRAs that change only linear layers work.') + raise ValueError(INT8_UNMERGEABLE) if manifest.get('precision') != 'fp8': raise ValueError('LoRA variants require the prepared FP8 model') from .adaln_assets import SLIM_FORMATS, restore_projections diff --git a/freevideo_engine/lora_online_cache.py b/freevideo_engine/lora_online_cache.py index 2126994c..879ed47b 100644 --- a/freevideo_engine/lora_online_cache.py +++ b/freevideo_engine/lora_online_cache.py @@ -1,4 +1,5 @@ """Prepare small, streamable adapter factors beside unchanged FP8 weights.""" +from collections import Counter import hashlib import json import os @@ -9,7 +10,7 @@ import torch from safetensors.torch import save_file -from .lora_cache import MODULATION, _targets, index_adapter, prepare as prepare_fused +from .lora_cache import INT8_UNMERGEABLE, MODULATION, _targets, index_adapter, prepare as prepare_fused from .media_request import digest, verify_files from .monitoring import save from .storage import fingerprint @@ -45,10 +46,11 @@ def prepare(cache, adapters, output_root=None): precision = manifest.get('precision') # Low-rank branches run beside the base projection, so they need no change # to FP8 or ConvRot int8 weights. - # The int8 package cannot merge AdaLN projections, so those adapters alone - # are skipped and counted in the report; every other patch still applies. + # The int8 package cannot merge AdaLN projections, full differences, biases or + # layers outside its linear projections, so those patches alone are skipped and + # counted by category in the report; every low-rank linear patch still applies. skip_modulation = precision == 'int8' - if precision not in ('fp8', 'int8') or not _supported(adapters, manifest.get('linears', {}), skip_modulation): + if precision not in ('fp8', 'int8') or (not skip_modulation and not _supported(adapters, manifest.get('linears', {}), False)): # Full-difference, bias and modulation patches retain the existing # exact preparation path. Never silently omit an unsupported patch. path, report = prepare_fused(cache, adapters, output_root) @@ -84,6 +86,8 @@ def prepare(cache, adapters, output_root=None): for patch in index_adapter(adapter, targets, skipped if skip_modulation else None): block = (patch['target'].split('.')[1] if patch['target'].startswith('transformer_blocks.') else 'root') groups.setdefault(block, []).append(patch) + if skip_modulation and not groups: + raise ValueError(INT8_UNMERGEABLE) output.mkdir(parents=True, exist_ok=True) started = time.perf_counter() records, specs, block_bytes = [], {}, {} @@ -139,7 +143,8 @@ def prepare(cache, adapters, output_root=None): verify_files({'loras': adapters}) root_bytes = block_bytes.get('root', 0) summary = dict(targets=sum(len(spec['modules']) for spec in specs.values()), - adapter_count=len(adapters), skipped_modulation=len(skipped), factor_bytes=sum(block_bytes.values()), + adapter_count=len(adapters), skipped_modulation=sum(1 for c, _ in skipped if c == 'modulation'), + skipped=dict(sorted(Counter(c for c, _ in skipped).items())), factor_bytes=sum(block_bytes.values()), root_bytes=root_bytes, max_block_bytes=max((v for k, v in block_bytes.items() if k != 'root'), default=0)) online = dict(version=1, blocks=specs, block_bytes=block_bytes, summary=summary) From 55b9d9ed7a786afca8f63518b963202b9854c864 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 19:12:47 +0000 Subject: [PATCH 08/21] Image worker: record the Python stack of a native crash in the failure report --- freevideo_engine/exit_diagnostics.py | 40 +++++++++++++++++++++++++++ freevideo_engine/image_gen/service.py | 32 +++++++++++++++++---- freevideo_engine/image_gen/worker.py | 9 ++++++ 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/freevideo_engine/exit_diagnostics.py b/freevideo_engine/exit_diagnostics.py index e51349c2..a7be152e 100644 --- a/freevideo_engine/exit_diagnostics.py +++ b/freevideo_engine/exit_diagnostics.py @@ -5,6 +5,10 @@ exit is seen. Only numbers and fixed words leave this module, never text from the worker, so a diagnostic report stays free of user data. """ +import os +import re +import tempfile + from .system import windows # Exit codes of a Windows process that the system, not the program, ended. @@ -99,6 +103,37 @@ def exit_reading(code, *, requested_stop=False, system=None): return result +CRASH_FILE_ENV = 'FREEVIDEO_CRASH_FILE' +_FRAME = re.compile(r'^\s+File "(?P[^"]*)", line (?P\d+) in (?P[A-Za-z0-9_<>.]{1,80})\s*$') +_FRAME_OUT = re.compile(r'^[A-Za-z0-9_.-]{1,80}:\d{1,7} [A-Za-z0-9_<>.]{1,80}$') +MAX_FRAMES = 40 + + +def new_crash_file(): + """An empty file the worker's crash handler writes the native crash's Python stack to.""" + handle, path = tempfile.mkstemp(prefix='freevideo-crash-', suffix='.txt') + os.close(handle) + return path + + +def read_crash_stack(path): + """Frames of the crashed thread as 'file.py:line function': no folder, no other text.""" + try: + with open(path, encoding='utf-8', errors='replace') as stream: + text = stream.read(64 * 1024) + except (OSError, TypeError): + return [] + frames = [] + for line in text.splitlines(): + match = _FRAME.match(line) + if match: + name = re.split(r'[\\/]', match.group('file'))[-1] + frames.append('%s:%s %s' % (name[-80:], match.group('line'), match.group('name'))) + if len(frames) >= MAX_FRAMES: + break + return [frame for frame in frames if _FRAME_OUT.match(frame)] + + _SAMPLE_KEYS = _FIELDS + ('process_working_set_bytes', 'process_private_commit_bytes') _CLASSES = frozenset({'clean', 'stopped_by_us', 'terminated_externally', 'interrupted', 'native_crash', 'error_exit', 'killed_by_signal', 'signal', 'unknown'} @@ -124,4 +159,9 @@ def clean_reading(value): memory = clean_sample(value.get('memory')) if memory: result['memory'] = memory + stack = value.get('native_stack') + if isinstance(stack, list): + frames = [row for row in stack[:MAX_FRAMES] if isinstance(row, str) and _FRAME_OUT.match(row)] + if frames: + result['native_stack'] = frames return result diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index a2d33bb1..0558c067 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -4,6 +4,7 @@ from copy import deepcopy import json import math +import os from pathlib import Path import queue import re @@ -85,6 +86,12 @@ def close(self): for stream in (self.process.stdin, self.process.stdout, self.process.stderr): if not stream.closed: stream.close() + crash_file = getattr(self.process, 'crash_file', None) + if crash_file: + try: + os.remove(crash_file) + except OSError: + pass class Service: @@ -541,10 +548,20 @@ def spawn(self, root, machine, env, descriptor): '--device', machine.get('device_backend', 'cuda'), '--idle-seconds', self.idle_seconds) command[0] = machine['comfy_python'] + from ..exit_diagnostics import CRASH_FILE_ENV, new_crash_file + crash_file = None + try: + crash_file = new_crash_file() + except OSError: + pass env = dict(env, FREEVIDEO_RUNTIME_LOCK_FD=str(descriptor)) - return processes.popen(command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace', - pass_fds=(descriptor,), supervise=True, start_new_session=True) + if crash_file: + env[CRASH_FILE_ENV] = crash_file + process = processes.popen(command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace', + pass_fds=(descriptor,), supervise=True, start_new_session=True) + process.crash_file = crash_file + return process def work(self, root, machine): from ..locking import runtime_lock @@ -701,8 +718,13 @@ def worker_gone(session, heard, requested_stop=False): code = getattr(error, 'exit_code', None) if code is not None: # The worker cannot report its memory once it is gone: read this process's view now. - from ..exit_diagnostics import exit_reading, memory_sample - error.exit_reading = dict(exit_reading(code, requested_stop=requested_stop), memory=memory_sample()) + from ..exit_diagnostics import exit_reading, memory_sample, read_crash_stack + reading = dict(exit_reading(code, requested_stop=requested_stop), memory=memory_sample()) + if reading.get('exit_class') == 'native_crash': + stack = read_crash_stack(getattr(session.process, 'crash_file', None)) + if stack: + reading['native_stack'] = stack + error.exit_reading = reading return error @staticmethod diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index 3ca7012e..7c5503c8 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -181,6 +181,15 @@ def main(): args = parser.parse_args() if not 0 < args.idle_seconds < float('inf'): parser.error('--idle-seconds must be positive and finite') + # A native crash (access violation, driver fault) leaves no Python error: the crash + # handler writes the Python stack to a file the service reads once the worker is gone. + crash_file = os.environ.get('FREEVIDEO_CRASH_FILE') + if crash_file: + try: + import faulthandler + faulthandler.enable(file=open(crash_file, 'w'), all_threads=False) + except (OSError, RuntimeError, ValueError): + pass stream = None if os.name == 'nt': from ..win32 import private_stdin From 52b31eb3d5dea5768e28daaa4ce933f6ec21c33d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 19:18:47 +0000 Subject: [PATCH 09/21] Replace a restored FreeVideo tab from another version with the current workflow --- freevideo_engine/comfy_launcher_api.py | 7 ++++ web/launcher.js | 45 +++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/comfy_launcher_api.py b/freevideo_engine/comfy_launcher_api.py index b2a6270d..afef4c56 100644 --- a/freevideo_engine/comfy_launcher_api.py +++ b/freevideo_engine/comfy_launcher_api.py @@ -139,6 +139,13 @@ async def launcher(request): @server.routes.get('/freevideo/launcher/workflow') async def workflow(request): value = json.loads((source / 'example_workflows' / 'FreeVideo-All-in-One.json').read_text(encoding='utf-8')) + # The tab restored from an older version is replaced when this stamp differs. + from .release_notes import installed_details + version = installed_details(source).get('version') + if isinstance(version, str) and version: + for node in value['nodes']: + if node.get('type') == 'FreeVideoGenerate': + node.setdefault('properties', {})['freevideo_version'] = version return web.json_response(value) # web/health.js posts what it found; the launcher shows a failure to the diff --git a/web/launcher.js b/web/launcher.js index 2184a827..a0dcfb7f 100644 --- a/web/launcher.js +++ b/web/launcher.js @@ -28,6 +28,45 @@ function cachedStudioProject() { return false; } +const finite = (value, low, high) => typeof value === 'number' && Number.isFinite(value) && value >= low && value <= high; +// A tab restored from another FreeVideo version is replaced by the current default workflow. +// Only the creation choices come across, by name; a value that is not valid now is dropped. +// Positions 0-4 of the generate node and 0 of the media node have never moved between versions. +function currentStudioWorkflow(saved, bundled) { + const stamp = bundled.nodes?.find(node => node.type === 'FreeVideoGenerate')?.properties?.freevideo_version; + const old = saved.nodes?.find(node => node.type === 'FreeVideoGenerate'); + if (!stamp || !old || old.properties?.freevideo_version === stamp) return null; + const graph = structuredClone(bundled), node = graph.nodes.find(row => row.type === 'FreeVideoGenerate'); + const [text, width, height, seconds, seed] = Array.isArray(old.widgets_values) ? old.widgets_values : []; + if (typeof text === 'string' && text.trim()) node.widgets_values[0] = text; + if (finite(width, 256, 4096) && Number.isInteger(width)) node.widgets_values[1] = width; + if (finite(height, 256, 4096) && Number.isInteger(height)) node.widgets_values[2] = height; + if (finite(seconds, 1.625, 60)) node.widgets_values[3] = seconds; + if (finite(seed, 0, 2 ** 53 - 1) && Number.isInteger(seed)) node.widgets_values[4] = seed; + const media = graph.nodes.find(row => row.type === 'FreeVideoMedia'); + const assets = (saved.nodes.find(row => row.type === 'FreeVideoMedia')?.widgets_values || [])[0]; + try { + const rows = JSON.parse(assets); + if (media && Array.isArray(rows) && rows.length && rows.every(row => row && typeof row === 'object' && !Array.isArray(row))) media.widgets_values[0] = assets; + } catch { /* Not a media list: keep the empty one. */ } + let rows = []; + try { rows = JSON.parse(saved.nodes.find(row => row.type === 'FreeVideoLoRAStack')?.widgets_values?.[0]); } catch { /* No LoRA. */ } + rows = Array.isArray(rows) ? rows.filter(row => row && typeof row.name === 'string' && row.name && finite(row.strength ?? 1, -10, 10) + && (row.enabled === undefined || typeof row.enabled === 'boolean')).slice(0, 32) : []; + const input = node.inputs?.findIndex(row => row.name === 'loras') ?? -1; + if (rows.length && input >= 0) { + const id = Math.max(graph.last_node_id || 0, ...graph.nodes.map(row => Number(row.id) || 0)) + 1, link = Math.max(graph.last_link_id || 0, ...graph.links.map(row => row[0])) + 1; + graph.nodes.push({id, type: 'FreeVideoLoRAStack', pos: [60, 660], size: [460, 200], flags: {}, order: 2, mode: 0, + inputs: [{name: 'adapters', type: 'STRING', widget: {name: 'adapters'}, link: null}, {name: 'previous', type: 'FREEVIDEO_LORAS', link: null}], + outputs: [{name: 'loras', type: 'FREEVIDEO_LORAS', links: [link], slot_index: 0}], + properties: {'Node name for S&R': 'FreeVideoLoRAStack'}, widgets_values: [JSON.stringify(rows)]}); + graph.links.push([link, id, 0, node.id, input, 'FREEVIDEO_LORAS']); + node.inputs[input].link = link; + graph.last_node_id = id; graph.last_link_id = link; + } + return graph; +} + async function existingStudioWorkflow(target, bundled) { // Restored background tabs are lazy: load() reads the host's saved draft, // whereas the workflow store's openWorkflow() only selects the tab record. @@ -36,6 +75,9 @@ async function existingStudioWorkflow(target, bundled) { // Leave the current graph intact; the existing startup check reports an // unopened workflow without replacing the user's project. if (!saved) return null; + // Only the shared tab is ours to replace; a workflow the user saved under another name stays. + const current = target.path === 'workflows/FreeVideo.json' ? currentStudioWorkflow(saved, bundled) : null; + if (current) return restorePromptDraft(api, current, target.path); const graph = structuredClone(saved); if (!graph.nodes?.some(node => node.type === 'FreeVideoGenerate')) { const node = structuredClone(bundled.nodes.find(node => node.type === 'FreeVideoGenerate')); @@ -96,7 +138,8 @@ async function keepInFront(workflow, opened) { // FreeVideo tab, including its media and connections. Reuse that project. if (studioWorkflow(app.graph)) { const current = app.graph.serialize(), target = store?.activeWorkflow; - const restored = await restorePromptDraft(api, structuredClone(current), target?.path || '__existing__'); + const replaced = target?.path === 'workflows/FreeVideo.json' ? currentStudioWorkflow(current, workflow) : null; + const restored = await restorePromptDraft(api, replaced || structuredClone(current), target?.path || '__existing__'); if (JSON.stringify(restored) !== JSON.stringify(current)) await app.loadGraphData(restored, true, true, target || 'FreeVideo'); return; From d56fa1678d43f698e329bd3ea099ec655090a88d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 19:47:54 +0000 Subject: [PATCH 10/21] Studio: add another long video to the queue while one is generating --- web/studio.css | 3 +++ web/studio.js | 34 ++++++++++++++++++++++++---------- web/studio_chain.js | 45 +++++++++++++++++++++++++++++++++++++-------- 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/web/studio.css b/web/studio.css index 0f8b32af..47ebee48 100644 --- a/web/studio.css +++ b/web/studio.css @@ -222,3 +222,6 @@ .fv-lora-problem{grid-column:var(--lora-lead,2)/span 3;grid-row:4;display:flex;flex-direction:column;align-items:flex-start;gap:6px;font-size:var(--fv-micro)}.fv-lora-problem p{margin:0;color:var(--fv-danger)}.fv-lora-problem[hidden]{display:none}.fv-lora-why summary{cursor:pointer;color:var(--fv-muted)}.fv-lora-why pre{margin:6px 0 0;white-space:pre-wrap;word-break:break-all;color:var(--fv-muted);font:inherit;font-family:ui-monospace,monospace}.fv-lora-row[data-unusable=true] select{color:var(--fv-muted)} .fv-lora-row[data-unusable=true]{cursor:default}.fv-lora-row[data-unusable=true] :is(input[type=number],input[type=checkbox]){opacity:.45}.fv-lora-row[data-unusable=true] select{opacity:1}.fv-lora-row[data-unusable=true] :is(.fv-lora-trigger,.fv-lora-range){display:none} +/* The queued count follows the part number; when the line is too narrow it wraps out of sight. */ +.fv-studio .fv-chain-position:has(.fv-chain-queued){display:flex;flex-wrap:wrap;column-gap:0;line-height:1.4;max-height:1.4em;overflow:hidden} +.fv-studio .fv-chain-position .fv-chain-queued{white-space:pre} diff --git a/web/studio.js b/web/studio.js index 0e726585..33aaacdb 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1079,6 +1079,11 @@ export function openStudio(node, {extendFrom = null} = {}) { stage.append(stageMedia); previewSpace.append(stage); const progress = createGenerationProgress(t, undefined, {compact: true, api}); stage.append(progress.element); const chainLine = el('div', '', 'fv-muted fv-chain-position'); chainLine.hidden = true; progress.element.prepend(chainLine); + // The queued count sits after the part number; a line too narrow for both keeps the part number only. + const setChainLine = text => { + const main = el('span', text), more = chainController?.waiting; + chainLine.replaceChildren(main, ...(more ? [el('span', t(` · ${more} more queued`, ` · 另有 ${more} 条排队`), 'fv-chain-queued')] : [])); + }; progress.updateReport({report_id: node.freevideoReportId}); cleanup.push(() => progress.dispose()); cleanup.push(createPreviewScene(stage, progress.element, stageMedia)); @@ -1282,13 +1287,13 @@ export function openStudio(node, {extendFrom = null} = {}) { }, })); const queueController = studioQueues.get(node); - let chainWasActive = false; + let chainWasActive = false, stopNote = ''; chainController = createStudioChain({api, node, onProgress: (message, chain) => { const position = chainPosition(chain, message); const joining = readChainResult(chain).joining, waiting = chain.state === 'queued' && message.phase === 'prepare'; - chainLine.textContent = joining ? t('Joining the parts.', '正在接合各段。') + setChainLine(joining ? t('Joining the parts.', '正在接合各段。') : waiting ? t(`Waiting in queue · Part ${position.index + 1} of ${position.count}`, `正在排队 · 第 ${position.index + 1} 段,共 ${position.count} 段`) - : t(`Part ${position.index + 1} of ${position.count}`, `第 ${position.index + 1} 段,共 ${position.count} 段`); + : t(`Part ${position.index + 1} of ${position.count}`, `第 ${position.index + 1} 段,共 ${position.count} 段`)); progress.element.querySelector('.fv-generation-label').hidden = joining || waiting; progress.updateReport(message); showProgress(message); }, @@ -1304,12 +1309,14 @@ export function openStudio(node, {extendFrom = null} = {}) { partList.update(chain, active); busy = active || Boolean(queueState?.running || queueState?.pending.length); queued = chain?.state === 'queued'; chainLine.hidden = !active; - chainLine.textContent = outcome?.joining ? t('Joining the parts.', '正在接合各段。') - : t(`Part ${position.index + 1} of ${position.count}`, `第 ${position.index + 1} 段,共 ${position.count} 段`); + setChainLine(outcome?.joining ? t('Joining the parts.', '正在接合各段。') + : t(`Part ${position.index + 1} of ${position.count}`, `第 ${position.index + 1} 段,共 ${position.count} 段`)); if (active && node.properties.freevideo_chain?.width) previewSize(node.properties.freevideo_chain.width, node.properties.freevideo_chain.height); if (!active) { cancelling = false; hideProgress(); } if (!active) progress.element.querySelector('.fv-generation-label').hidden = false; if (chain?.state === 'cancelled') status.textContent = t('Cancelled', '已取消'); + // After a stop with videos queued the next one starts at once; its line says so until something else is done. + if (stopNote) { status.dataset.error = 'false'; status.textContent = stopNote; } resumeChain.hidden = !['cancelled', 'failed', 'interrupted'].includes(chain?.state); queueActions.hidden = !active && !busy && resumeChain.hidden; updateRunButton(); @@ -1419,15 +1426,15 @@ export function openStudio(node, {extendFrom = null} = {}) { runMode = multiple || extension.source ? 'single' : ['single', 'batch', 'loop'].includes(node.properties?.freevideo_run_mode) ? node.properties.freevideo_run_mode : runMode; const chain = chainController?.state, position = chainPosition(chain); - const chainReason = !chainController?.active ? '' : readChainResult(chain)?.joining + const chainReason = !chainController?.active || chainController.canQueue ? '' : readChainResult(chain)?.joining ? t('Joining parts…', '正在拼接…') : chain?.state === 'queued' ? t('Queued…', '排队中…') : t(`Generating part ${position.index + 1}…`, `正在生成第 ${position.index + 1} 段…`); - generate.disabled = submitting || chainController?.active || lengthControl.blocked || (runMode === 'loop' && queueState?.looping); + generate.disabled = submitting || (chainController?.active && !chainController.canQueue) || lengthControl.blocked || (runMode === 'loop' && queueState?.looping); generate.textContent = submitting ? t('Adding…', '正在添加…') : chainReason || (runMode === 'batch' ? t(`Add ${count.value || '…'} videos`, `添加 ${count.value || '…'} 个视频`) : runMode === 'loop' ? (queueState?.looping ? t('Looping', '循环中') : t('Start loop', '开始循环')) - : busy ? t('Add to queue', '添加到队列') + : busy ? t('Add to queue', '加入队列') : previewFirst.checked && !previewFirst.disabled ? t('Generate preview', '生成预览') : t('Generate video', '生成视频')); generate.title = (lengthControl.blocked ? lengthControl.message : submitting ? t('Adding…', '正在添加…') : ''); for (const b of modeButtons) b.setAttribute('aria-pressed', String(b.dataset.mode === runMode)); @@ -1523,7 +1530,12 @@ export function openStudio(node, {extendFrom = null} = {}) { if (cancelling) return; try { cancelling = true; updateRunButton(); - if (chainController.active) { await chainController.cancel(); cancelling = false; updateRunButton(); return; } + if (chainController.active) { + stopNote = chainController.waiting > 0 ? t('Stopped. The next queued video is starting.', '已停止,队列中的下一条开始生成。') : ''; + await chainController.cancel(); cancelling = false; updateRunButton(); + if (stopNote) { status.dataset.error = 'false'; status.textContent = stopNote; } + return; + } await syncQueue(); const id = queueController.state().running?.id; if (!id) { cancelling = false; updateRunButton(); return; } @@ -1559,7 +1571,7 @@ export function openStudio(node, {extendFrom = null} = {}) { let held = null, accepted = false; const onAccepted = () => { accepted = true; }; try { - lastQueueError = null; failure.clear(); node.freevideoFailure = ''; node.freevideoFailureReport = null; + stopNote = ''; lastQueueError = null; failure.clear(); node.freevideoFailure = ''; node.freevideoFailureReport = null; capturing = true; updateRunButton(); status.dataset.error = 'false'; runOptions.open = false; lengthControl.freeze(); if ((!extension.source || prompt.value.trim()) && await enhancer.beforeGenerate() === false) return; @@ -1584,8 +1596,10 @@ export function openStudio(node, {extendFrom = null} = {}) { capturing = false; for (const section of controls.querySelectorAll('.fv-section')) section.inert = false; if (lengthPlan?.count > 1) { + const waitingBefore = chainController.waiting; const chain = await chainController.start(snapshot, lengthOptions, {onAccepted}); if (disposed) return; + if (chain && chainController.waiting > waitingBefore) { status.dataset.error = 'false'; status.textContent = t('Added to the queue. It starts when the current video finishes.', '已加入队列,当前视频完成后自动开始。'); return; } if (!chain) { if (source) throw new Error(extendText(t, 'unavailable')); lengthControl.fallback(); if (!seconds.reportValidity()) return; await queueController.start(snapshot, {count: batchCount, repeat: chosenMode === 'loop', seedIndex, onAccepted}); } } else await queueController.start(snapshot, {count: batchCount, repeat: chosenMode === 'loop', seedIndex, onAccepted}); diff --git a/web/studio_chain.js b/web/studio_chain.js index 87cbeaac..88b74c31 100644 --- a/web/studio_chain.js +++ b/web/studio_chain.js @@ -3,6 +3,7 @@ import {buildChainRequest} from './length_plan.js?v=20261011-length'; export const CHAIN_PROPERTY = 'freevideo_chain'; const activeStates = new Set(['queued', 'running', 'cancelling']); +const MAX_WAITING = 5; const finite = value => typeof value === 'number' && Number.isFinite(value) && value >= 0; const post = body => ({method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); @@ -55,14 +56,17 @@ export function singleRunSnapshot(snapshot, node, plan) { export function createStudioChain({api, node, onState = () => {}, onProgress = () => {}, onResult = () => {}, onError = () => {}, pollMs = 2000, now = () => Date.now()}) { - let chain = null, poller = null, disposed = false, epoch = 0, submitting = false; + let chain = null, poller = null, disposed = false, epoch = 0, submitting = false, promoting = false; + // Chains the server took while another was running; the next one is shown when the running one stops. + const waiting = []; let previous = {}, lastMessage = {}, delivered = null, errorShown = null, corrected = false, etaAt = 0, lastIndex = -1; const timers = new Set(); const clock = {now, setTimeout(fn, ms) { const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; }, clearTimeout(id) { clearTimeout(id); timers.delete(id); }}; function clearPolls() { for (const id of timers) clearTimeout(id); timers.clear(); } function refresh() { clearPolls(); return poller?.poll(); } - const active = () => submitting || activeStates.has(chain?.state) || chain?.output_state === 'joining'; + const running = () => activeStates.has(chain?.state) || chain?.output_state === 'joining'; + const active = () => submitting || running(); async function json(path, options, allowMissing = false) { const response = await api.fetchApi(path, options); if (response.status === 404 && allowMissing) return null; @@ -102,6 +106,7 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( chain = value; onState(chain); const result = readChainResult(chain); if (active()) progress({}, fresh); + else if (waiting.length) void promote(); if (result.error && errorShown !== result.error) { errorShown = result.error; onError(Object.assign(new Error(result.error), {joining: chain.output_state === 'failed', ...chainPosition(chain)})); } @@ -116,6 +121,24 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( } return active(); } + async function promote() { + if (promoting || disposed) return; + promoting = true; + let again = false; + try { + while (waiting.length && !disposed && !running()) { + const next = waiting[0]; + let value; + try { value = (await json(chainRoute(next.id), {cache: 'no-store'}, true))?.chain; } + catch { again = true; break; } + waiting.shift(); + if (!value?.id || value.state === 'cancelled') continue; + adopt(value, next.metadata); break; + } + } finally { promoting = false; } + if (again && !disposed) clock.setTimeout(() => void promote(), 3000); + else if (!disposed && chain) onState(chain); + } function watch(fresh = false) { poller?.dispose(); clearPolls(); const id = chain.id, version = epoch; @@ -135,9 +158,10 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( const known = node.properties?.[CHAIN_PROPERTY]?.id; const reply = await json('/freevideo/chains', undefined, true); if (disposed || version !== epoch || chain) return; - const found = reply?.chains?.find(row => String(row.node) === String(node.id) && (known ? row.id === known - : activeStates.has(row.state) && row.client_id && row.client_id === api.clientId)); + const mine = row => String(row.node) === String(node.id) && activeStates.has(row.state) && row.client_id && row.client_id === api.clientId; + const found = reply?.chains?.find(row => String(row.node) === String(node.id) && (known ? row.id === known : mine(row))); if (!found) return; + for (const row of reply.chains) if (row.id !== found.id && mine(row)) waiting.push({id: row.id, metadata: null}); const value = (await json(chainRoute(found.id), undefined, true))?.chain; if (disposed || version !== epoch || chain || !value) return; adopt(value); @@ -177,10 +201,12 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( } recover(); return { - get active() { return active(); }, get state() { return chain; }, + get active() { return active(); }, get state() { return chain; }, get waiting() { return waiting.length; }, + // A chain that is running does not stop the next one from being added behind it. + get canQueue() { return !disposed && !submitting && running() && chain?.state !== 'cancelling' && waiting.length < MAX_WAITING; }, async start(snapshot, options, {onAccepted = null} = {}) { - if (disposed || active()) throw new Error('chain_busy'); - submitting = true; epoch++; + if (disposed || submitting || (running() && waiting.length >= MAX_WAITING)) throw new Error('chain_busy'); + submitting = true; try { const body = buildChainRequest(options, {node: node.id, prompt: snapshot.output, extra_data: {extra_pnginfo: {workflow: snapshot.workflow || {}}}, ...(api.clientId ? {client_id: api.clientId} : {})}); @@ -189,7 +215,10 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = ( if (!value.id) throw new Error('chain_missing'); onAccepted?.(); const inputs = snapshot.output[String(node.id)].inputs; - submitting = false; adopt(value, {width: inputs.width, height: inputs.height, seconds: options.seconds}); + const metadata = {width: inputs.width, height: inputs.height, seconds: options.seconds}; + submitting = false; + if (running() && chain.id !== value.id) { waiting.push({id: value.id, metadata}); onState(chain); return value; } + epoch++; adopt(value, metadata); return value; } finally { submitting = false; } }, From b127b2a0cb47e495f7685f679f3f882109c35e9c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 20:53:55 +0000 Subject: [PATCH 11/21] Parts of a long video and continued parts sample 8 + 3 steps --- freevideo_engine/comfy_continuation.py | 15 +++++++++++++++ freevideo_engine/comfy_nodes.py | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/comfy_continuation.py b/freevideo_engine/comfy_continuation.py index 9a26d556..035bd8d5 100644 --- a/freevideo_engine/comfy_continuation.py +++ b/freevideo_engine/comfy_continuation.py @@ -41,6 +41,21 @@ def node_frames(value): return value +def part_steps(base_steps, refine_steps, step_cache): + """(base_steps, refine_steps, step_cache, note) for a continued part or any part of a video made in parts. + + The engine continues a video only in two passes, and two passes run only with 8 first-pass steps + (two_pass.TWO_PASS_BASE_STEPS). Other steps would let the first part finish and refuse the next, + so every part samples 8 + 3, the same for the whole film. note is None when nothing changed. + """ + from .two_pass import TWO_PASS_BASE_STEPS + if base_steps == TWO_PASS_BASE_STEPS: + return base_steps, refine_steps, step_cache, None + return (TWO_PASS_BASE_STEPS, 3, 'none', + 'Parts of a long video and continued parts use %d + 3 steps with "Two-pass acceleration". ' + '%d first-pass steps apply only to a single video.' % (TWO_PASS_BASE_STEPS, base_steps)) + + def request_frames(value): """Translate the node's duration sentinel into the bridge's optional count.""" if type(value) is not int or value != 0 and (value < 39 or (value - 5) % 17): diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index e54ffee5..e1665b6a 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -5,7 +5,7 @@ from comfy_api.latest import ComfyExtension, InputImpl, io, ui from . import comfy_bridge -from .comfy_continuation import node_frames, optional_text, request_options +from .comfy_continuation import node_frames, optional_text, part_steps, request_options from .default_prompt import DEFAULT_PROMPT References = io.Custom('FREEVIDEO_REFERENCES') @@ -144,6 +144,7 @@ def validate_inputs(cls, text, width, height, seconds, seed, continue_from='', f if options.get('continue_from'): # A continued part turns on two passes, skips the preview and takes keyframes as pictures (execute). two_pass, preview = True, False + base_steps, refine_steps = part_steps(base_steps, refine_steps, step_cache)[:2] options.update(two_pass=True, preview=False, refine_from=refine_from or None, media=dict(first=False, last=False, conditioning_info=False)) comfy_bridge.validate_request(text, width, height, seconds, seed, **options) @@ -181,6 +182,11 @@ def execute(cls, text, width, height, seconds, seed, first=None, last=None, chain = block_context(PromptServer.instance, node_id) options = request_options(continue_from, node_frames(frames)) notes = [] + if options.get('continue_from') or chain is not None and chain.get('count', 1) > 1: + base_steps, refine_steps, step_cache, note = part_steps(base_steps, refine_steps, step_cache) + if note: + two_pass = True + notes.append(note) if options.get('continue_from') and not two_pass: two_pass = True notes.append('Continuing a video needs "Two-pass acceleration", so it is on for this part.') From 2f1e84fd05c20ef5aeaabac7cf00dc2d09be752f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 21:07:20 +0000 Subject: [PATCH 12/21] Guide how-tos: short walk-throughs over the real interface, started from Settings User guide --- MANIFEST.in | 1 + web/editor_references.js | 2 +- web/first_run_guide.css | 2 + web/first_run_guide.js | 103 ++++++++++++++++++++++++++------- web/howto.js | 79 +++++++++++++++++++++++++ web/howto/editor-refs-en.png | Bin 0 -> 2754 bytes web/howto/editor-refs-zh.png | Bin 0 -> 2633 bytes web/howto/extend-button-en.png | Bin 0 -> 8494 bytes web/howto/extend-button-zh.png | Bin 0 -> 10895 bytes web/setup.css | 3 + web/studio.js | 2 +- 11 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 web/howto.js create mode 100644 web/howto/editor-refs-en.png create mode 100644 web/howto/editor-refs-zh.png create mode 100644 web/howto/extend-button-en.png create mode 100644 web/howto/extend-button-zh.png diff --git a/MANIFEST.in b/MANIFEST.in index 2309e5f5..04d51b02 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,6 +6,7 @@ recursive-include example_workflows *.json recursive-include web *.js *.css include web/vendor/three/LICENSE recursive-include web/assets *.svg *.png *.webp +recursive-include web/howto *.png recursive-include web/fonts *.woff2 recursive-include freevideo_engine/assets *.png *.ico recursive-include freevideo_engine/launcher *.qml diff --git a/web/editor_references.js b/web/editor_references.js index 9476cbfc..9dbb7903 100644 --- a/web/editor_references.js +++ b/web/editor_references.js @@ -20,7 +20,7 @@ export function createEditorReferences({t, source, size = () => null, error = () const add = el('button', 'fv-ed-ref-add'); add.type = 'button'; add.setAttribute('aria-haspopup', 'menu'); add.setAttribute('aria-expanded', 'false'); add.setAttribute('aria-label', t('Reference image', '参考图')); add.title = t('Reference image', '参考图'); - add.textContent = '+'; + add.textContent = '+'; add.dataset.fvGuide = 'editor-refs'; const menu = el('div', 'fv-ed-ref-menu'); menu.hidden = true; menu.setAttribute('role', 'menu'); const picker = el('input'); picker.type = 'file'; picker.accept = 'image/*'; picker.multiple = true; picker.hidden = true; root.append(add, chips, menu, picker); diff --git a/web/first_run_guide.css b/web/first_run_guide.css index 38655732..1c74a519 100644 --- a/web/first_run_guide.css +++ b/web/first_run_guide.css @@ -20,6 +20,8 @@ .fv-studio .fv-guide-bubble[data-side=below] .fv-guide-arrow{top:-7px;border-left:1px solid var(--fv-border);border-top:1px solid var(--fv-border)} .fv-studio .fv-guide-wait{position:absolute;top:76px;right:24px;z-index:40;display:flex;align-items:center;gap:16px;max-width:calc(100% - 48px);padding:14px 18px;background:var(--fv-raised);border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);box-shadow:0 8px 24px #0005} .fv-studio .fv-guide-wait p{margin:0;font-size:14px;line-height:1.6} +.fv-studio .fv-guide-bubble[data-side=center] .fv-guide-arrow{display:none} +.fv-studio .fv-guide-picture{display:block;width:100%;height:auto;margin:0 0 14px;border-radius:8px;border:1px solid var(--fv-border)} @media(prefers-reduced-motion:reduce){.fv-studio .fv-guide-layer *{transition:none!important;animation:none!important}} .fv-studio .fv-entry-showcase{display:grid;place-items:center;pointer-events:auto;padding:24px;background:#0008;overflow:auto} diff --git a/web/first_run_guide.js b/web/first_run_guide.js index c4aa3b8a..85affe19 100644 --- a/web/first_run_guide.js +++ b/web/first_run_guide.js @@ -14,8 +14,6 @@ export const REPLAY_TEXT = { unavailable: ['Open a creation workflow to view the guide.', '请先打开创作工作流,再查看指引。'], manual: ['User guide', '使用说明'], }; -// The written guide is in Chinese only, so only the Chinese interface links it. -export const MANUAL_URL = 'https://github.com/FlashML-org/FreeVideo/blob/main/docs/zh-CN/%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E.md'; export const CHAPTERS = [ {id: 'describe', anchor: '[data-fv-guide="describe"]', added: false, @@ -120,6 +118,13 @@ export function showGuide({all = true} = {}) { return (all ? current?.replay() : current?.show()) || Promise.resolve(false); } +// A how-to is a short list of steps played over the live interface. The same layer, ring and bubble as the guide are used, +// nothing is recorded, and a step whose control is not on screen shows its picture instead of a ring. +export function showHowTo(steps) { + current = [...document.querySelectorAll('dialog[open]')].map(dialog => guides.get(dialog)).filter(Boolean).at(-1) || current; + return current?.tour(steps) || Promise.resolve(false); +} + const el = (tag, text, cls) => { const e = document.createElement(tag); if (text != null) e.textContent = text; if (cls) e.className = cls; return e; }; const visible = e => { if (!e?.isConnected || e.closest('[hidden], [inert]') || !e.getClientRects().length) return false; @@ -131,18 +136,42 @@ const visible = e => { }; const clamp = (n, low, high) => Math.max(low, Math.min(high, n)); +// The tutorial book's "walk me through it" buttons call this; the how-tos load only when one is asked for. +if (typeof window !== 'undefined') window.freevideoHowTo = {start: id => import('./howto.js').then(({startHowTo}) => startHowTo(id)), + available: id => import('./howto.js').then(({howToAvailable}) => howToAvailable(id))}; + export function createGuideReplayEntry({dialog, cn, openWorkspace, replay = showGuide}) { const text = key => REPLAY_TEXT[key][cn ? 1 : 0]; const section = el('section', null, 'fv-guide-replay'), row = el('div', null, 'fv-guide-replay-row'); const button = el('button', text('again')); button.type = 'button'; const status = el('div', null, 'fv-muted'); status.setAttribute('role', 'status'); status.hidden = true; const actions = el('div', null, 'fv-guide-replay-actions'); - if (cn) { - const manual = el('a', text('manual')); manual.href = MANUAL_URL; manual.target = '_blank'; manual.rel = 'noopener noreferrer'; - actions.append(manual); - } - actions.append(button); - row.append(el('h3', text('title')), actions); section.append(row, status); + // "User guide" lists a few how-tos; choosing one plays it over the real interface. + const manual = el('button', text('manual')); manual.type = 'button'; manual.setAttribute('aria-haspopup', 'menu'); manual.setAttribute('aria-expanded', 'false'); + const menu = el('div', null, 'fv-guide-howtos'); menu.setAttribute('role', 'menu'); menu.hidden = true; + const closeMenu = () => { menu.hidden = true; manual.setAttribute('aria-expanded', 'false'); }; + manual.onclick = async () => { + if (!menu.hidden) { closeMenu(); return; } + try { + const {availableHowToList, startHowTo} = await import('./howto.js'); + menu.replaceChildren(...(await availableHowToList(cn)).map(item => { + const choice = el('button', item.title); choice.type = 'button'; choice.setAttribute('role', 'menuitem'); + choice.onclick = async () => { + closeMenu(); + try { + if (!await openWorkspace()) { status.textContent = text('unavailable'); status.hidden = false; return; } + await new Promise(resolve => { dialog.addEventListener('close', resolve, {once: true}); dialog.close(); }); + await startHowTo(item.id, {openWorkspace}); + } catch { status.textContent = text('unavailable'); status.hidden = false; } + }; + return choice; + })); + menu.hidden = false; manual.setAttribute('aria-expanded', 'true'); + } catch { status.textContent = text('unavailable'); status.hidden = false; } + }; + section.addEventListener('keydown', event => { if (event.key === 'Escape' && !menu.hidden) { event.stopPropagation(); closeMenu(); manual.focus(); } }); + actions.append(manual, button); + row.append(el('h3', text('title')), actions); section.append(row, menu, status); button.onclick = async () => { button.disabled = true; try { @@ -162,6 +191,7 @@ export function createGuideReplayEntry({dialog, cn, openWorkspace, replay = show // Custom targets return viewport rectangles on every layout pass. They never // insert hit targets or change the editor's pointer handling. export function guideTarget(dialog, chapter, targets = {}) { + if (chapter.target) return chapter.target(dialog); if (targets[chapter.id]) return targets[chapter.id](); const nodes = (chapter.anchors || [chapter.anchor]).map(selector => dialog.querySelector(selector)); if (nodes.some(node => !visible(node))) return null; @@ -182,7 +212,7 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou let disposed = false, touched = false, stopped = false, layer = null, bubble = null, ring = null, arrow = null; let steps = [], index = 0, record = null, manual = false, restoreFocus = null, pollTimer = null, frame = null; let serverBlocked = false, checking = false, preparing = false; - let waiting = null, replayTimer = null; + let waiting = null, replayTimer = null, touring = false; let showcase = null; const heldKeys = new Set(); const target = chapter => dialog.querySelector(chapter.anchor); @@ -191,6 +221,7 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou return value && visible(value.node) && value.rect.width > 0 && value.rect.height > 0 ? value : null; }; const available = () => chapters.filter(c => resolved(c)).map(c => c.id); + const shown = step => resolved(step) || (step.image && touring); function blocked() { return !dialog.open || !dialog.isConnected || dialog.inert || serverBlocked || isBusy() // Create stays open underneath Edit. It is an inactive workspace, @@ -217,7 +248,7 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou layer?.remove(); layer = bubble = ring = arrow = null; if (restore && focused && restoreFocus?.isConnected) restoreFocus.focus({preventScroll: true}); } - function interrupt() { if (automatic && dialog.open) attempted.add(group); stopped = true; clearTimeout(pollTimer); hide(); } + function interrupt() { if (automatic && dialog.open) attempted.add(group); stopped = true; touring = false; clearTimeout(pollTimer); hide(); } function remember() { if (manual) return; // Empty projects acknowledge the optional trim chapter too, so importing @@ -226,13 +257,23 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou // A failed write stays quiet. This page's single attempt is already used. saveGuideRecord(api, record).then(ok => { if (!ok && !disposed) interrupt(); }); } - function finish() { remember(); stopped = true; clearTimeout(pollTimer); hide(); } + function finish() { remember(); stopped = true; touring = false; clearTimeout(pollTimer); hide(); } function position() { if (!layer) return; if (blocked()) { interrupt(); return; } if (showcase) return; // The entry card is centered with CSS at every size. let value = resolved(steps[index]); if (value?.reveal) { value.reveal(); value = resolved(steps[index]); } + if (!value && steps[index].image && touring) { + // No control to point at: the picture stands in for it, in the middle of the window. + const frame = dialog.getBoundingClientRect(), wide = Math.min(440, frame.width - 48); + ring.hidden = arrow.hidden = true; bubble.style.width = `${wide}px`; + const box = bubble.getBoundingClientRect(); + Object.assign(bubble.style, {left: `${clamp(frame.left + (frame.width - box.width) / 2, 12, innerWidth - box.width - 12)}px`, + top: `${clamp(frame.top + (frame.height - box.height) / 2, 12, innerHeight - box.height - 12)}px`}); + bubble.dataset.side = 'center'; + return; + } if (!value) { steps.splice(index, 1); if (!steps.length) interrupt(); @@ -253,14 +294,16 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou const below = {side: 'below', x, y: r.bottom + 24}, above = {side: 'above', x, y: r.top - b.height - 24}; const narrow = innerWidth <= 680; const candidates = editor - ? [{...above, x: steps[index].id === 'editor-describe' ? clamp(r.left, xmin, xmax - b.width) : x}] + ? steps[index].id === 'editor-refs' && narrow ? [below, above] // the description box stays in view + : [{...above, x: steps[index].id === 'editor-describe' ? clamp(r.left, xmin, xmax - b.width) : x}] : steps[index].id === 'longer' ? [{side: 'below', x: clamp(r.right - b.width, xmin, xmax - b.width), y: r.bottom + 24}, above, below] : narrow ? [{side: 'below', x, y: ymax - b.height}, above, below] : [{side: 'right', x: r.right + 24, y}, {side: 'left', x: r.left - b.width - 24, y}, below, above]; const fits = c => c.x >= xmin && c.y >= ymin && c.x + b.width <= xmax + .5 && c.y + b.height <= ymax + .5 && !(c.x < r.right + 8 && c.x + b.width > r.left - 8 && c.y < r.bottom + 8 && c.y + b.height > r.top - 8); - const place = candidates.find(fits); + // A how-to's ring may be a whole section; with no clear spot the bubble sits over its lower part rather than ending the walk-through. + const place = candidates.find(fits) || (touring && !editor ? candidates[0] : null); if (!place) { interrupt(); return; } const style = getComputedStyle(node); const pad = editor ? 0 : 4; @@ -288,13 +331,17 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou if (layer) { bubble.focus({preventScroll: true}); remember(); } return; } + if (touring) try { steps[index]?.prepare?.(dialog); } catch { /* the step then falls back to its picture */ } resolved(steps[index])?.reveal?.(); - while (steps[index] && !resolved(steps[index])) steps.splice(index, 1); + while (steps[index] && !shown(steps[index])) steps.splice(index, 1); if (!steps[index]) { interrupt(); return; } - const {node, rect: r} = resolved(steps[index]), bounds = dialog.getBoundingClientRect(); - const clip = node.closest('.fv-controls, .fv-preview-column')?.getBoundingClientRect() || bounds; - if (!editor && (innerWidth <= 680 || r.top < clip.top + 8 || r.bottom > clip.bottom - 8)) - node.scrollIntoView({block: 'center', inline: 'nearest', behavior: 'instant'}); + const found = resolved(steps[index]), bounds = dialog.getBoundingClientRect(); + if (found) { + const {node, rect: r} = found; + const clip = node.closest('.fv-controls, .fv-preview-column')?.getBoundingClientRect() || bounds; + if (!editor && (innerWidth <= 680 || r.top < clip.top + 8 || r.bottom > clip.bottom - 8)) + node.scrollIntoView({block: 'center', inline: 'nearest', behavior: 'instant'}); + } layer = el('div', null, 'fv-guide-layer'); layer.dataset.chapter = steps[index].id; layer.dataset.group = group; ring = el('div', null, 'fv-guide-ring'); ring.setAttribute('aria-hidden', 'true'); bubble = el('div', null, 'fv-guide-bubble'); bubble.setAttribute('role', 'dialog'); bubble.setAttribute('aria-modal', 'false'); @@ -310,8 +357,12 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou const next = el('button', index === steps.length - 1 ? t('Got it', '知道了') : t('Next', '下一步'), 'fv-guide-next'); next.type = 'button'; next.onclick = () => { if (index === steps.length - 1) finish(); else { remember(); index++; draw(); } }; actions.append(next); footer.append(dots, actions); arrow = el('span', null, 'fv-guide-arrow'); arrow.setAttribute('aria-hidden', 'true'); + if (!found && steps[index].image) { + const picture = el('img', null, 'fv-guide-picture'); picture.alt = ''; picture.src = new URL('./howto/' + steps[index].image + (cn ? '-zh' : '-en') + '.png', import.meta.url).href; + picture.onerror = () => { picture.remove(); follow(); }; picture.onload = follow; bubble.prepend(picture); + } bubble.append(message, footer, arrow); layer.append(ring, bubble); dialog.append(layer); - resizeObserver.disconnect(); resizeObserver.observe(node); resizeObserver.observe(dialog); + resizeObserver.disconnect(); if (found) resizeObserver.observe(found.node); resizeObserver.observe(dialog); position(); if (layer) next.focus({preventScroll: true}); } async function poll() { @@ -378,13 +429,23 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou await retryReplay(); return true; } + async function tour(list) { + if (disposed || !dialog.open) return false; + attempted.add(group); clearTimeout(startTimer); interrupt(); cancelReplay(); + stopped = false; serverBlocked = false; manual = true; touring = true; record = null; + steps = list.map(step => ({...step})); index = 0; restoreFocus = document.activeElement; + draw(); + return Boolean(layer); + } const touch = event => { - if (!dialog.contains(event.target) || layer?.contains(event.target)) return; + if (touring || !dialog.contains(event.target) || layer?.contains(event.target)) return; if (automatic) attempted.add(group); touched = true; if (layer) finish(); else stopped = true; }; const key = event => { + // While a how-to plays the person types and clicks as asked; only Escape belongs to the bubble. + if (layer && touring && event.key !== 'Escape' && !bubble.contains(event.target)) return; if (layer) { heldKeys.add(event.key); event.stopImmediatePropagation(); if (event.key === 'Escape' || event.key === 'Enter') { @@ -426,7 +487,7 @@ export function createFirstRunGuide({dialog, api, cn, isBusy = () => false, grou // The editor can be opened with a bare request helper that has no events. const events = api?.addEventListener && api.removeEventListener; if (events) { api.addEventListener('status', status); api.addEventListener('execution_start', execution); } - const handle = {show, replay, suspend() { cancelReplay(); interrupt(); }, + const handle = {show, replay, tour, suspend() { cancelReplay(); interrupt(); }, handleKey(event) { if (!['keydown', 'keypress', 'keyup'].includes(event.type)) return false; const handled = !!layer || heldKeys.has(event.key); diff --git a/web/howto.js b/web/howto.js new file mode 100644 index 00000000..b4ca7ad8 --- /dev/null +++ b/web/howto.js @@ -0,0 +1,79 @@ +import {showHowTo} from './first_run_guide.js'; + +// Short walk-throughs played over the live interface (the same ring and bubble as the first-run guide). +// A step names its control; when that control is not on screen (nothing generated yet, say) `image` stands in for it. +const shown = node => Boolean(node?.isConnected && node.getClientRects().length); + +// The whole Media section, from its title to the last line, and it is opened if it was folded. +const mediaSection = dialog => { + const title = dialog.querySelector('[data-fv-guide="media"]'), details = title?.closest('details'); + if (!details || !shown(details)) return null; + const r = details.getBoundingClientRect(); + return {node: details, rect: {left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height}, reveal: () => { details.open = true; }}; +}; +// Only the part on the timeline: the selected clip or range, else the first part. +const selectedPart = dialog => { + const find = selector => [...dialog.querySelectorAll(selector)].filter(shown)[0]; + const node = find('.fv-ed-clip[aria-selected="true"]') || find('[data-fv-guide="editor-timeline"] .fv-ed-selection') || find('.fv-ed-clip'); + if (!node) return null; + const r = node.getBoundingClientRect(); + return {node, rect: {left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height}}; +}; +const HOWTOS = [ + {id: 'reference-video', view: 'studio', title: ['Make a video from reference images', '参考图生成视频'], + steps: [ + {id: 'media', target: mediaSection, en: 'Add images here and set each one as a reference, first frame, or last frame.', + zh: '在这里添加图片,每张可选作参\u2060考、首\u2060帧或尾\u2060帧。'}, + {id: 'describe', anchor: '[data-fv-guide="describe"]', en: 'Type @ in the prompt and choose an image, then say what it does in the scene.', + zh: '在描述里输入 @ 选一张图,并说明它在画面里做什么。'}, + {id: 'generate', anchor: '[data-fv-guide="generate"]', en: 'Choose "Generate video".', zh: '点“生成视频”。'}]}, + {id: 'long-video', view: 'studio', needsParts: true, title: ['Make a longer video', '连续的长视频'], + steps: [ + {id: 'seconds', anchor: '[data-fv-guide="seconds"]', en: 'Enter a longer duration. It is made in parts automatically.', + zh: '把时长填长一些,会自动分段生成。'}, + {id: 'generate', anchor: '[data-fv-guide="generate"]', en: 'Choose "Generate video".', zh: '点“生成视频”。'}, + {id: 'extend', anchor: '.fv-action-extend', image: 'extend-button', + en: 'To carry on from a finished video, click "Extend" on its result.', zh: '想接着做下去,在生成好的视频上点“接着生成”。'}, + {id: 'longer', anchor: '[data-view="editor"]', en: 'Or add parts one by one in "Edit".', zh: '也可以到“剪辑视图”里一段段接。'}]}, + {id: 'editor-extend-refs', view: 'editor', title: ['Add reference images when extending in Edit', '在剪辑视图延长时加参考图'], + steps: [ + {id: 'editor-timeline', target: selectedPart, en: 'Select the part to continue.', zh: '选中要接着生成的那一段。'}, + {id: 'editor-refs', anchor: '[data-fv-guide="editor-refs"]', image: 'editor-refs', + en: 'Click + next to the prompt to upload or generate images, up to 3.', zh: '点描述框旁的 +,上传或生成图片,最多 3 张。'}, + {id: 'editor-describe', anchor: '[data-fv-guide="editor-describe"]', en: 'Describe it, then choose "Generate".', zh: '写一句描述,再点“生成”。'}]}, +]; + +export const howToList = cn => HOWTOS.map(({id, title}) => ({id, title: title[cn ? 1 : 0]})); + +// Devices that cannot make longer videos in parts have no Extend button, so that how-to is not offered. +// An unreadable answer keeps it, as the length field's own line does. +async function canMakeParts() { + try { + const {api} = await import('../../scripts/api.js'); + const reply = await api.fetchApi('/freevideo/chains'); + return !(reply?.ok && (await reply.json())?.continuation === false); + } catch { return true; } +} +export async function howToAvailable(id) { + const howTo = HOWTOS.find(item => item.id === id); + return Boolean(howTo) && (!howTo.needsParts || await canMakeParts()); +} +export async function availableHowToList(cn) { + const parts = await canMakeParts(); + return HOWTOS.filter(item => parts || !item.needsParts).map(({id, title}) => ({id, title: title[cn ? 1 : 0]})); +} + +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + +// Plays one how-to. The editor ones open the Edit view first; the others open the creation panel. +export async function startHowTo(id, {openWorkspace = async () => (await import('./view_navigation.js')).openCreativeWorkspace()} = {}) { + const howTo = HOWTOS.find(item => item.id === id); + if (!howTo || !await howToAvailable(id) || !await openWorkspace()) return false; + if (howTo.view === 'editor') { + document.querySelector('dialog.fv-studio [data-view="editor"]')?.click(); + for (let i = 0; i < 40 && !document.querySelector('dialog.fv-editor[open]'); i++) await wait(100); + } + await document.fonts.ready; + await wait(250); + return showHowTo(howTo.steps); +} diff --git a/web/howto/editor-refs-en.png b/web/howto/editor-refs-en.png new file mode 100644 index 0000000000000000000000000000000000000000..fc0fee0d3d47d925955b4283006a6ed6a53af976 GIT binary patch literal 2754 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 literal 0 HcmV?d00001 diff --git a/web/howto/extend-button-en.png b/web/howto/extend-button-en.png new file mode 100644 index 0000000000000000000000000000000000000000..cbb4f624defb13ddae5a0cf684d580b2d970779a GIT binary patch literal 8494 zcmV+}A<^E6P)Qw`-G494Eo{?%Mc(*kSFBW9Qg~ zB*r$j<0KqDF~-=AF{cp50R&iFLMOTZ(^& zuU@^b?olOXj710$2$9GGCifl*5|RF#=SW?}#RHENxHvnw3o64=o&traa6*e1yy({l zaP&roroAZ8xsYN;S#c2Gb)+|Igq6EkD3JW17kZczejEjG(vJt`f<$qIA!h+sAtGEL zB%z4Xi+*ize7zx>Tp&VGOpM~-bTnCtLwfBO;SrOxx|9V5h&0&dYSMU6EQf#kh4IpDI786 zG8BlbC~|RPgv+IKgdyhzPEQEIj7Ef@I6TGl`cy=K9|l7^Jg$rauOpMJ9CS5$fWwxhZJa-x}t0&nrw&aA^;3Y~sehLheA~^n9U4 zBqR`5Q0XJ-`T-d-jz3?808)yH81j)1x6dWY-b(|!?zxR%U}{1XM+`0!p&kW@!MP-| zfy9tUiX#lUtdKIFhjAeg`eO39CQQ#OtWZcBYpl+sj}r_YV#sSaLI`nvS&ID&cgKnx z#7$~pw>m;0Z}CSy7;&zss9vDq^OsYM&JhY%XB40wf>$oIposTuD5`WV$>rtvEr@7x zjAA0}BFIt9;Sq!w9Qny3J^8V@cYeDfqM8cd>gajD{ zp^&R$h$9VRqzO65ka1#!%cb*>0pChVsqXawTAT|=OV$Z zuF$DAdh9^-+f{s*Mj%KGdFZMeY(xZ(Ad!`n9VD${l($7PR7oHe5(s)e6xSuBW){y% z&YfsV%h4vJsWmzwIpJ`ERwvOs1{T3x-)InmDZ~rY{QM&<}7lBg3#;r z8jVJ+RwCvp;cVDOkbS@60tEV{acDv2)(G^Kb%8+n<<%Xo02$I)a`1cx(A--V# zcgDP^r}eW^-R}pJz;BcW|)sadB}*qXA5y z-Ql!598QOWbdo{TAneF#oA{63VoJ4Ku1p9*>6+A3DkY&-t9$$U0i3CW-A$=8B~nFW zKOzSALK~Md>j&#Iib`R&Me=}oOL0eLZZQN0p-?8}6s8s3(E3fKW5_}~fq1w=p-4?m z(dsn){R2aTHV2G6rxWv9Njyr9ByzeewF7K6z-Tb&bvo;yb=aW@)+JpEOi2M(%TIwM zU)9B`fnad_R%X>vyhyS z)-FK{9h}F2w1$MtNpssT{lnvVaVgc!!PiJnONHLp-*0yL2*J|Jz!9Utkz~31=<#kO z$Uy^xgF`B{GCs~^vAB%aa2kXHxto9olIBw&l|Hx{sBd5mSH+hr&C1ZzP;!C@VIaf@VNJT>XsVCbt4DZnMQdY5U6qjYzPakBB4JNPzO2$YD3qY8iw_ z60pkll#8U5kz|v2p%7XNwIM$Lrn`{LkYaRV2nq7%uY|VdIt$TOQ=AFbIA|mS#~Sfl z9bW4K%f9{rv&m#6=<7rO^zNvwg zut1=!iF2^`Jwze!W;H&3y+EZkWKEdObyly_yJE=>2Z|=(I9u{^u72ZsKxlXBbb7Az zJ_Y*A*^PyeeOS@AhG6g*yOdI~g zU#WV%7KSEM&Xrysd3ZR^?8fhrxdHmo>a>Uh!wL)s*q4@UF)RWFQ-kbq?TK@eTAeU| zi1N9x?KFuJM&+Rq3~~K8`sT5)AZc_l*D1AHW3xL^lmRDJh0k$p6b}cFN~h5vjy?nm z9HJ?ZEr!L#Q2-B%d657{Yzr}5DDb(o8nvH1SQd&P^9ciLtpP;_LXA!gQlZw-+S*^K zgo1F`?MyjWdZ}`WRs`Y2s$}Kxgpp8icEw=@h8BSpmh$6)p&M7Qa8@Y9$GNcDQ=w9$ zYaG)1JHn8U04S6iim*%$1QGD|hS2)s$Ro?~SB$pHVE zauj^YIULXOruRyN@hAa(Cn{G(5QEExlRJL{|50?oPY;bJ_nrUpZ`S^9Aq`E3M{j5M zyt@0TWldFwP%v~ioZG$Ou2b**mU-&nA0L5C+=U4fxcUKCJ>``XVUY)I%kuK_H{RU3 zX3ZKL`-wO?mk|lP0yBn{k&*t3XP?{f>~onJX-FETPMiAXn_C}y>@g%1MDQ3E3#Wth zuX^AGoDQ2R_*_TQN-4M1wbS2^!^7E+U#UH^3y4Z@G$dz?nsQU#>~BNZz3(S+BBMf;&0n5qEtjxzB|F6Wv1o-Rm zC)RD;xUspp#j7fkN<(@TN0-l;Gi%Y}n{&tHs#K~|r%r5reQRge@I)w7JrM-g+durX zNaT?W^PLvGZjVZs5Ds9!?MhreRIWDZK5e9aEP71hL#T+^ZL%UIv$3 zsZc!p@IyD2mImZsT3QNn`b+yk^oY>lpzY!%59;gdxzZ;kB{8o>LcFbET@$4DS`q~d zY$FB_c4xr=v$gJtbvB#5vGK~lfEl{h6Hlzm^q&$~QCL*?;DbNR&&zLYY_eFb)2C0r z^^V(VNL!fQ%4ZMg-g3=z_}sN*cK4!014F@$Br$WpG7)%=*`j5N)p5@zp-e^{rb2RkPn%A&+OXVUQ-pHK1QVr zd?s64`SICZe<)mhU%|W;u0OxpbM}MHla~HFK^*UYkx#~BzZtd>6ib4r4p9{x35Rba!doLY&moB+@aA*DT zPmw^V)VkbRuGi~2F1p6U)y4~zuRYb*(U?7b;ZX0@uixGX!&s@(B#kQQZ*8y*njN;G z-YYe(lnv+M*w=g+sZ`0M$5XkT+xte_h2x1ig&;q(`xWNxYAQbXX7?7SlN>dvti9&M z;Vu6)Xolt?ubkQk!Kw738L4?w!0^=^*y&DA{$u;En@=B#&lsnRPj0I|=FP^_kuoQO zO(DB3S5XsFcg-m{X8LbihcZ{bvA(avehMB$ z>5ZkMva@fxX&yYK+O}gy<&mT9ZS8RO(4ixH_Ur)yW^38fJ3HIk_aE3_R8;iihadLk zcIVP%Ma2^u8XGgSGVZzeUP`!P#mawL^eu~Z5X{`vY18RG=rl`BVy64J80x1+q zWn6at)y8vReY&q)NXjlG2+Hv!4XIhP?%$x+8a{jB2Lo-5Hmljap=r2lUfj1$q1Md) zmuIzx1VhTG>OHU39oGvDJbGZ$Px@LK zKn`^QQy{-;>gpPvfBwH64yS@pEMB}QGt2Xhrp}IzjT@iq>FERK zB`q~2At~W%ch74I9=2U$p33_uqd%3Jd7*DZ1N9$!E`*`QU?Vz<+%7vHxss zZuVW3k(K%EFV;iVHveIB#gU_k&0&TApMC!MoH^xrl7dV(c)iyP? z{Bq;7y*+)KHg8T#O^#1W?CHMx;;&xp?CgZI({Gsm^Pl~+q@?7)f&IuG{Qx!oV#9`( z)>ge)*>h$=K(p2D1BM79zGW%{3g+E4*w^LtO;A_unQ`Sy z3(8j_G4QP8;LzXU8r8{pli`?{GhU@N^fp~WuD#$BE~EEh*+n`Wri?LhsX0B37c4z( z_JQ8S>EnGF84}W|eYVErEU*_gYd?J}rlqIDZW%}%Jzi^oP(7J4&UKgGW>@}djlSvB zm-S!m9q4Fs+J?;Ct_B-uZ-ir9MlN+Zq4K>5;+tNG^a(S;OX{qvQfp1Nf!?&acLt1m za|bOG=qxJF2CLpys3}=<;2ro+rBtZ3UJZ~;4fyVe*cHebjUZ?#WZ|rN((g4iI0Wth z*R{wU=W1xI_qc540T|DxPM$h@<}57tAoR0y$2MkVva_>wI&E(5=+|C<%~#*l)Kn_h zuC6W^uE0AwI$_0XGMJPKC6M;^j-KuwkPlji=(-|;)G~yaJ7;cdYwM;>n_HTj={JKv z*FE|4zyELzB%mx8EBbo-o_XfU6?d&Hn^TsbKlUdN{rKH?-+$*FstbW0I$e4oU8dY* z91VKt>AGq*n^CZ&wNNv;4$Qc@yi1)Wakg$w6eT3QtfMRr!U!JsQDF5LRYYgATN z%TRJ^Dx2jB=mLn*ytq(d1+)kruT3OMZhL@GsA>;?0NmqKh(d2`9UP4*naJ0198N^- zmQ@ho&LGlBeUqoZt-3@i>PtLgN?9#|?uOXRS<;6ku%&~-oZbAM=@wkUAFa(rg@ zpXw@hWlbu}nYOU{v$t&YT?-_i;vhD6{?k8q)qSIKTg>$FrR?b#U+h-9U**lYaZ&b^ zo2V3^OvJ4LZ;hf97PI-~mtOwSkAI}mXkL8rh5o*Np2R&@{o-86^$wseXY^=_E4{{@ zj()dy-_+E+efxHw<*L10lUI;WPZs!PbV8&h;&wEH$FOM2Pm;;CI$cjsPi<{&K+ce& zuC^9V>2x|)Z_CHcX3JY|ytVyre_OQZ=DY9y?ya}o_WlR&)AJ!|dI0nN#FG&F&pc9b zq@(=^JgZfzlmQiReHg<$;Bb(2_4OaT=gG?9aMXu5I2dl9i`sXp*1Hzu6L0w!jnVZ1 zHlMdeKZ^s1(AQV)29lcZibTYv=k~WXcGgs7PMqDDrM_anvt$m%@ z(-tOV6_B=}_H)PKlEg8^Sa%{xeSC`Al-So=53wz9yV@_gB5QG(V+26x0-Y-p zvb*j~_SE^VPHm?qe=9nQ#s6HiAXG&trez?LKlk0gn>F(PKakXOY{!;PhW`1Xlg?y_9uxI>?Xr?7n&N;`WZNmw)>*JYs`2z6w6#t}XxXx*-&(lfcfb4nXP{5x$4r=QX~X~cTh9r0WmD(jbTwzGT>S^`7q$meP4XRWfcgvmDF7;S3nqk;Y|y! zT)C2v5UqA*!RW0Wy_X5{q)mSr%!_| zvkqFpvqPao1mawl-nLHH12y(j`uO16QUJ|W2VjL|NY2QeeOp1!PuEGXi$^LraFrsYoydZYY#EBo5(t~R-z zMUIf=d{_<3U6SZh5SobJ%0Z4Ketp@f^|;F8t2}H z>*}s0cdk(z;@Zw1uRr!lQg(sH2#zY@*1-A;pZ}%d>%Y4-@Sslvy#5m>_onvyuDJ*~ z>2!kmm^Z&XE!FQM;9ng(cJR<4htq+Vj|)bV@zz_GQ00WcmPVsC81%b#?HaTUZr`!( zk+l%2zxVX1(;w~naN6W);|mHPO-4q>p4}fSm8yAV^JdMQ+1lDtQE`-(Co|eczWw&w zI=v2_o#p2hyz%KH%tf=J7M|#)bvph@-g=@Ls;Zs6l0+>96jd1&1*^}p` zM9a-^c_%$e+L*Zyaw zb!%*>>+d)7m7eZ!(>cQC2q$;MW~XHHwkYseF0Qq+R6x9(+EOsWK}zP7KAAj*=!O6L zfPwD@a&eN4qd+-A!TJR0-q}#y`wdSCar)mi0KpH-T~FC#xJE$m%boTiHaRsaNy6_( z{}6MS5S$Y)Q;sm?2wNT%pnZXh1*D$>g@DA62c0nkIAUXZg#r{J=n}y`7d+-iFqI6vR2Z})|#PuWoUdi8c z5r#Yvo$mM8Sutp}s+6JHmrn?mv)7B@&&Pp-_UBxDv1{ zkd(|t#<1`ULHNhBWPn=<1dpy*A-Lx`2F;iD??f>~0Q|iY$MfQs`*CPx`;eI?4UF{+ z3=9~JMj^4GP;&x9&d({v%*UGo&sLD`O`!w*1Bkb^K!GNsF+d(MNOAcj0Allcy?$Uo zurnlP&a657erN3&6vMTPuKKgJl^^q`>Fe*e+wIWpPz?S6oq%Qr%me(%SQN`6FBNTBa(Fz6uk4w}Kyct=;4%A>$gI5a|lZye#r!aY(>BBxY4$O8WkT>9Au~-ZM zqt0e`kWP|6y<~ad5n;&X<%nAjVXK7rIH!Z`=;|byJGn^O1vG9kyA)`o6zCvX(|9<+ z;E2Ilj^3X#HsWVv#3ze24^$|*!Kj8ypb z#oCIGPV9WiX6dE>REPf<4wSYr49-ncG1Kjw}dDffh5NP{zfXG-{3O16Ty%ngd9&#bO>2!EqHKkV^vS zN~KomC|zkaef{REJ>oZq^1N}43td8L#)O&4xs&43a57z;qYRcy8u(R( zdGt(Po{m8AVY%cZ1ui)qTypuq)rCSKA1R!x{8Hqh0<%YPahZ?)BZZmB^R5z3c2R+m zz8{BbZIYKGUM?_urW}d7txz6AG(dW564)FuxTFG!yg}3=f|g5y(UI4PL=*(J9LI{v zD<3Hwg+R{FqX=@@E^>s;r6b+vl0MnVl)cc~$}r!?5OxS{O(ZK9Ss+O_h+0I@a!DBd z@*0tdg2a}SF&;HMiXfZq!oN5wa6POi~%|@K97VA_OptDTgV-)986gIb3S199m-P0>&R*PY zaf%~!hG&+FUydZp<$}2K@Yk2%mE%uof|plspvG8UIN(-DK1&xH@BCl!kMQ$jZ) zM9D{@=fx~Pvt9^W9t0$YT>4xWgD_ktmP@9&NUjAAKIf9~%VQ7+={Aas3$Bj3kKV`L4ZKO)JHHeyx>4WfP@m8V5bqv%L76l1e4@J z2rUVwg9Ty_wDW7-j>?qBkmX$zX;&GbhHLRp5+BGf_6>79p({XN5CDK^ry;R~Sq+C(r3yTt8UrBn3 zPIpJomh=_*3@YKIY43DX%L3HZL8ZTv#9s^xE}RG>RX&o{iC8BW@)NrC1Nrbho3fVG6x@h7R zu3b~;m`E`aXfB848jYkh9w~5fmmG8L(yfkmOr+>Il9H#ATcEU4dDrxJ zQ_o<|_%M2Fczv~Kk&ik~3q5ngO8X%64M@}>E>hq!t|*RaI}JrD-@TAHMih*6W2B0a zV%|YVK7x~q$Ul&{=cjc}ZRbh<6scmQ$Vb#V-z2^k`KV*85hLon2qS{%NK`Fn$c0YC0)3f$iUYoR5-fN{bd3!-Cz6^!x4))9&7h~y%u zBj+c^NCRJu5;0X8fclWwKQN*@4yFCDRzB)ue3E9q+7cluR5F7|6V(#YUsiYO$KlW>&PUY_X!n*N&6+|N1crv7!jTKVN_o1*f4D z&81*o(^;#E*=HXpU6tLPPNUYW!l5&+A2i`x2F9e@EUP%#Z*B<(11EN_Jhtv%p6+IW z7>P6vUt;e(kuw7PCFOsOo}_+WkIfJ5?|& ziE+U67v$xI!eLmotRIn2QY-?LJE=3z7kYc~xU}#QLR$QDloKYfi2wtd>9G5}K1sYF zug}RgKQA9_fy?P4i1dDrK*|?o+>n5e1Tg}Kc^#BfB;`O$KA#Vw1_*$Bem_J%R4tB2 z2ci@TGTIzDNIVfV9Gz*dMWfkr-i<%Y98j+IjRcr4so;KGOqL8s(V)hC>q$7`)06~* z5%@7!wH}XG!l&2oYZ#}4Pbi;ag~bI42BX1Xvsv99kI3%O>PKbk_R8xd#zY(D%^ryj zOTfpJI#NE5`SSu^A301Ea3C0f^oY%B^>{pdT!1y`r-2JkcsrqhxfmmAT`H$tms>t6 z!8a0MT~uC_`2DHml~+|xy-t1Tu-659&#qdOtnj`86l>rMu*d6Vj3$S}A+JLOI4)Z; zU#Y$nV-Y4wd=&NnGE6k^MOb{tko^7vB){(fEF+B3;jjzpB-T%Zz^KtJhlo#z)sa^@ zZLY%SN-d>G4~9`I$h38M<%8WPAWp48lp(B_2VoeRm-6i zjlR%iv1zjiybVg4qLgu;E{xR<@?;SwbFlzyR$B-T8A9PS2(nL7qJf_nV+ubU48akC zm0hTUIyjD_vJHpjzFiL{S}}@a!eOKkBP6D>CzX&en}8*JNH+$8K^QOlw>Wno5TS_1 zB86COB*8L^Qaox3iWbRas@NdySVB?CxUZMO;pg!JknDS5HyGkG4(Sr=>EtKESOY(4 z4A#_YwJO!&Wvf3&AjCKctRZ%g-VH>-YWEs&7{%^8U~il=Tdf?{GDQZ-VK7RBGm9$_ zy{OnA?Ko+QRB_)eIc39@gaN0`fk05sPf4h!lYe@#BrgE5g2ik`3V8vLtzRHp^|(Mp zksK3MOtfMojs;|S1gxG%5WSyLB%VMhXf>O)i_;*FDAcLRVn>Qa(!NHD(m|0b?z_cg zjmT3Xj0lD;F?+~NXvVycQ=);N99E{1yg<+Hx1d_H?qFfZFgF+u{0H{1tbug6i4rO(5C6poB|TY3s_)jWO0P%BJCB8B2QPFr>lj(n#{%} z#C%b}gaka!a4^8}gMN3=?;+tJ>m_0Z5-}maCt4jcPvM~OA8WSw#-JgOljD$ZNLn-L z!mlta#z=vmEJm977OTZ#u_A$AwPipAYLeGCGRDD!${ksm+;`1pOXZMC#>7ba0NZ<$ zg>@%3*v(t5TKa5dtFpCAOG=C;qhfuc%ts1XFbc&w0+pcV1u!-gdF5!*&U98 zLGay8e_h;qWQWn5(ROTKcIlAFZDhD%*Xo+J%Sy&f#Rg;BvAsPlCtPif{69$ES{ylS z1~=eqJHBuIAMY7B;bH@0>Zq^oZjRUVb~M}bOHop#X6-)-LGth}Gc(iU@lwM+YJH#v zgTZ8G*c;#l#mKOkBSN_wQn8(f9(riz%o$&-{lW~oY_{a&ZyzjJAD1I z#~!`xvdcgDzVf2 z>TRz-N+OZy!B1a1yz=jr)8}@@gr;B&d4hsFk&JY5yqO)d~oZ@z3a=y&D{I` zQcqX2kqhZD4tiV_)2v-?Jeb4;<_m#tjJ^b^Z!}q*C-_)rW&YC$R@;p%({=DMK z@4&yM&;4=d?p@I4gb5Q03kx$cY|t{dn9LA0#QMMtzJ*B$Y}=~OKd-2$ z{O(OR?c4WNb8`#Qv;P;$7Gru$5%cHtnzVcY>=+D#D=Hp*@cyi<99Vjb7A|`F>1XQd z>c9kQ$rp^S8dFtOwSC7fKUgJ%AsQMpx@yKnGuEzN8+pne-*flfqsNR9&fuW(^5>Vn z!2h{t_wMJOeO9|gpisGX3n)W0#|VUvyHYI_>!6*MpMSaE)ftN#yhqnA%Ntx(IAQ`{ z$867n)@`{(<8N9R^0{`u^-THX%L;~_mywm%a&%89b8CC>uHC0 zsjKmrV*ndX3_9*|DJs~SCK;)Dr5`tgt9>>fuaTd^I+WcuFsZYeAr z0Pg*YE3RCfhQIvj&k%{gSIa9Zzw@0d zHf-Efy>~A+CVwz^r1lt!JcBrU_H5u36cj?sIa%2TgYkQ}+!7lcTDfvnL&J#yMa92= z`YHC#fp9ecXEd+bGyI-L+hdA;6+i+=XlV~;hSY)tqpBbdeOZ@eKG3@u;&0gCJb-h1!=CQX`j!}Ztg z-m|N-y%XvHXYJZGHj9lQ>GO*~eSUvidmCMUR5VvhofwWr3P>0WsKyIvDG-fAFQPfN zNZSzkvk5C!wWu*d9G6f0RtzIgXYZfmqz_z=&{*r}%YpUMd-FOrtmfA1hGZ?t5qo(~E4}A33OMm@Vtg&!7 z;3HuY^s~1cPrvI~usa7od#!f!hny|=?1_0LW2R5IX%R}UDEU34z?a7n5nM5NFzl0F z`14b{ckPY~459Jk$1hyC5I#M2>{u@Qw56fFy*(0TQQCpc#)TJ7Ww-qZoi~2`@(-7{ zw45p~E^caWDl8~)I2?_QjdgYPP~YWtj~F>(_MACT6Au4Cv+u=m000mGNkl@yAB?4F}lg@hQd#NC_+F1(0GLtcoE_7%_ZAdq*c&t9$OfyT|EV`ux&G z3m+Oea#YjFMih$+sP`9u0i8Sd>S05Ne);A4nnN|5&NnwTulV3YuoQRw=*M6d{l37_ zqsK;_bIyYgKIrqov1yQB)3A2{IY0Y9&($6~PS*_$kv>+?bRlNsI|_B0mQufSrN5_Z z=tb9Ye?pGn=(=|-S^0w}f17Fn)oJ&s`kqs@_JTp++z|WX5te`MHFpL5zC)k>&Fsjp zoOTr}4UCU({LtIoa_-glbbWm|t9S_1yl}x2p!3H!e{|sE{~a{(lBWF|*hLjhE=HZT zyR&)k2W;@RYuWFhnJ=Ed8!+rOatDnpJ#RJ%hE5&Wic%?5IdjfTTa3VA$PS{&<8czc z&kuVv_EX3L&cAj3h~dMZeCo*)bq$2}DkHDQbLX9RE?&GCeA*+wd1UgG^C6=OZGvT~ zsyYYu4)C4HlO}E1vc+t%LdbCZ=rNPcy5RfYudA>BWW@>;o0Zm%c8ksG_xb02cOHD} z>8GEru08;w|43;#@%4)3%L%)8AUWAN0|%BZU%q_G1yjn)%RgJW zLho9Qu63HvkH}W*dkT08u~>e7L1RgPa2`r6 z%$PCrnrp80cs$FOuYklY|1N`-t5%*jal+`TsyptyYw7dPu{n%L-`{=roi(dh^K1RG zOE0_eKW;?2bVEHZP@qiILTILGDPf=Q$QR4lbAiZRWd}cfrEc3Nqb|EG35x)a1j{Gvt=5o)~8!;EiV3XA`Wa>4cps%Z;1`I^`l*>sdKVEd<0VDTWuNl+%Oi+#3gO86g7}3sW9E$M)2BjcaQxWuJzwnsPFZQ`#dGEqT z>iq={N<|PlOo3 z>+JsO{bz%&Zcpba&~xtU0cR(Zk!dhR(*!1)!(Lcg{qf5sRTn~L)Rt9na^HrC1z`^+ zG5fB>kf6`PBIGDzy8kD9MG0T&O_uy(r}JT*c|DM{29pPyVaV-KuM5M;FU*INg5sh9 z_uY5jlEsUGf7R7{tE%7SegpHnz>s`=v;A4RUqetPWak^Kj%FPc6Ba*r*LR)o#k zj2V@cL)NZc>+yQj_x}_=lI9fn`iR#JJ&^)zkD$X#mp?EPwwoSzsYq$NfWoj#zr9BDg^G)4;CTF1ER@&#~wc6{S|@Ym3b zZX9v(&B9M(nuE012uINmH&{>YsBUo>SnJe@7hrrPrJuV_8Kr=@0R zR@n$MlWob)L&0F}mgNOQ#zUQOD7fPvk9VFp1Q+o2bR7KbRmPHGV3?lfdV5|e#$?CFWBqqJa6nD^C!fr|INVm>uqK#1S; zNcfxxJ#NnnOJDfm5AOud|NP+(ZkH?GzMy_tS?ObsKL*(ykH^)~-ucJpm%&nC1sx2{^f3UFyz$<f`X^O?u)e6*>QwVg_rMH?hvXBAAVg%8rvZ7)gkjZQtHse3+c_ltqH)uQX zAvefa?I-uHg>wXmNA|q)On&9q%4u*R;%;j?=ko7^lV{U}7-bfh!!7_$9N=(l?6vo} zJ5H_n!<{G;KD_Ghmo0e{HWPlgv-Q~SvDf^ht-jjt?P;psIBeE;{a^^N!RPF76buY_ zdlYOIQs}dgFZW|nijbxm;VA?H5CvprWmzm1;Z=bHeE1ap_~)`^K)buTVt!H-Qvg2E z(6DOt>QN&{`Tf3EUwdWy_HEZ)cWqHo(Tgwr)oe2U@P~Kp*s*=x7wbE^ItL9JbjKZc zW;)od)8;K(cWm3vxAT*q-irk5?2EPQ)_t*#!?B#geUfoyX0vBTZjak!F+u-Z+gj^u z>%bDhIVI#TT3cJpHVc<)r1@|KpC3_5s?Qh5RtH5*R@(+B@4fZ`!nO-FmOb zZME6F?0ZvG;{^%T79(&YWe|UF1`@>!SjnQKR`z}H0;KUK&;KR&XXVuIu!4x)4!HOI z=OKKZeCw}O+fcL)C1Wn)apBPO<}7;IfQ>b4-mKs8`Rx1u1UejIj}LHWQF(sFn5F|; zVLLE(?%nP62ll-Y*Jq~##!0B+c|s%XR{*h&~!M;Eo-0*YiDTD&0fV?0F){Fwij@at;pTHD%gyY04< z-?~I7uhP;o;KQfzweF5CBx_B=dHvPb7B68Fjeeic+1*`QR_gQmDk=sWA~(=~{`u#S zGPGx8-2c;`X6595^4X_XU3pc}fB~b&RK*9z^`6#GrST3ZWIch+W`mtYPHyi0{rljw z&*OH1QTXJOPvBsG*6fR)e)>uFi3Xupm&FBIDMCv35vcM>fVCt>+Bi?K6BmcpNT`2{m(%>sM3edl&gBzAj-$LkIF{M=ML{>0np=O97bz2mvyL|gN@?=CthK;{2WOX5m88&Pvgfnp12C=lWryG)#_uYSAW+ofsR904)%oeB+ z@CQ^^sm@4&`lfQ#_Z_=juHurCv17+k>ttp+;8aq~k6b;bt8hA2kY8}mPws_-l}|tW ztf#A+F|+rH6&B_L=GX3DUw)A>nhlX(QUTV21q;9^zw*i}{FM3v0ks%`8ZRKlxIm7n zIRzz*NXnr?*|=F>z4yo6%bucsiWz3a#q*KwLa`13j|+}AY}o@K&i8eg74o&< zBQ=MQ96lVs2th)uv7KwLo?AGesJ^~FJ3D9k)M+`nIVh40?`Ut!%_~^6aM7D@zFAXK zvwIJFH$+h}>)}8BY~=^<{U3j=KmYlE`EmXBkw*t`cUTJg^TMBA;BmLp`Ov~eW53HH?y*{ues)G6JseV zD%rJb7avLP+glA3j(#SdH-WnozuVcfXU}fB_Y=ejNRBZLC(?8#(LPFQT~aKdQTY|; z_Ov!oKg}9YZpzSeya)RqE*BUrymdgsX57HZr{;^dJ?+iqlfS*^U(e*0j~;*BPxpWH zQghAr;WKZFN)-&UnSGa(yQ2xV73W_26RRV)aM<{U-K*M;e>HI2Oq12#(^{WhI%L$h z=4a%VKuu?JL;LZ)BQKdhWZGPa4kpcexPJRe$UZ>8p%l5OQJl_wufhzf^F_G0w8Ug$ zHf;FP<#tV*KE21;wR6{w0|yW7+_7Wov}rfqaKrEs!)lJyaP{F3!|U^1eDUl#vuE+6 z3V?q2{oJ_LezC@GS9#nXL8ibL@Iy2MTNSR&rlw{{@2^?C_Wk$YJAcagx83%ia2WCa zd+!mXbL=Yhk>*bYcOYs4J?Zt>JXu+JK50Sbh%Y#x~;txl8oTsPV-PV{pXk;kpXG|8@Gs@>Q;C9BWRW!(k^UKKX z2`S5hkdJ;fm-&sxRZWq?Gsle|*VWZsd;B=k zQ(!aVa=B6ZN_I|mV`F0kikCIQG1=1fmmvBtp2)DEMKM;Qi5EA6000UsNklzh}=L zJ^hJN$W!Qx1(dvfgb&B4k=PAUeSS2uM>t^~K_N##;vQIij)^G3k6Q$ynhVg2RsBCZ zP83E&Esr3Sp&=4waXShkz>2#LZ%+s6$AyTgM(ss9v5s$fyE}w?+Q`gBaWD`v8W|+p zKg;U#e55HSFXZDmA1Uw?wnKFD>Ejji>3e>pIWGX4!cdSXuDRe2gMqM;MvE~^4V>kR#|u%6Xqf$ecjFQC%_$mf1k#Chd<*2s z?S3OO8qF_oI1mgP8H1d!R3GykF(0Y$^%|iL^V0;gp245Lzf_QBKwDj1KD? zSQ1)?Bq&njlW_ClT}b8wIqLIxO-4q}|E5q)_XVh8q7X;0)M&(zs}pjBv4GFx0on37 z0zoW5bj@O_RPO(iqDT?5nrmVVUawcPrV|5aA+%7=qBQN>;ALND@XannOjDV6q{4T1 zwKncwk2sHnh41HMsmJSKU+BRw5`Mp=l>SS{OOt~-miYEK-(ZB4g2C-|BZc=Ga|ppOv0hVD_`$Cafk?h|4)hu`|DFnuYAM_T* z0^*qD9|*WTk+Tdgf}qWW)9nUTvDz{;%vJ)7HO3JN6eWQ%5@!08as*bZEf@&8 z-7e)iBCY4J(Q41-8&B7$y`9MfcB0FGXjFFQr5SKPeoQI4n+kh7C3jZSAeu+Fjp_OZN9#rtlbPuV zg~Dy^ZQ8Zh?e>5j0qKUrp|CK^NCa2uJxrm(dk45IK%B3RIo$+tnaqsCZU^537C{^Z zs8QYmaCbBk!8S#Rxuq-`N-WoaIEym$qU$TBTxl>c*a$f>L*zIEoh_om#;@VXFG_?- zC|I}Uqa*9y3i`b`{(02XBGEl_Qa z0srd;QS|gsG&gah1m_GG95WM|?<5mbOIATq)dl$#qcaN!S+WaF+)w?UEwK{td3;?h zJuN5NYpWZpH~G6;)qEEg??oee67`c{g$+ilmHnL-_QxC;Q$&XZzO-Lbq}{7iXylD% z{$McR^TS?%HM_~S2Im#91GiYr+>d1-%(>W9eEw;ri2b#L05}Yvm;Dd?MzR`>Sfe0a z#gq$TNxYzdMO64zDWAWT%sOhZvi*q02G^^6Eq?g)V^N=bTMqq{Dy?v#M#u-O#8;p+ z113~ak)pE2AU6n<`~sq9DP>qh356fq1#x3WNS;AeI(?1tRVnH-SRiAi)u5G+1$<29 zN2DG3t1n?upH=&x2DvG!!x_XBs%3>sH3G%+1rk18?_^Z2t#OS;{HHOCz#0Kcq#!h3 za?_-aW7j4^tnI5>G0n>A#YxjxcM|i3oU{1M;uI-Y)cIO*y0k#9WpRU~u_3;9(xQ9= z65%8(usXtcK?w_XCUG1c1JZ+$B1S0jDQ+U1bgY4>d?eu0IA`&h#3}i9wUvXCz=>D5 zMop1vSqvlX+Nw~LBLzxbh24mhiY*pKNzmltg|qZ5?K@OymC9Fj#9#=p4oWEb0wo$% zEkTDAik@4Q#g;~zccAZHkbbJgnsP!&Iq#q+M$m^-`PS*-r;GMRKl={WvT`UZP;HgU zS2ZH?+(8QY0z%c$h~`GFlCbEB6e!=8#12v8A;gr^(byPB+}7Yk&7_cbNEBZvVIkGa zm(W;8&LlrwxUY>%BUPFyEqp1~veHt*GEIpaBv~Y$xKRyeKwOf4s3~G~67OD!oKGaV z2DRrbRI#?a0~TPU1MA&Y#wSb0L6T1eD=A4n(#=m7PEUVQkpofWE6G2_nbo?YQpyn^ zrRNcdT3n#6eJBZwfKrVVDDCOWJ7}W9CZ&AtqD16A)Z+rlnnOA|a+dzvQMHw$EnZMt zu?Z;ApD)14Spi8&=i|?q@4eQ%gC?5zeg}?^WPF4Yp9V|$Nl_%jNcGdR_;lhVsh;}? zl=$=&n}&RWl%^0DP!u`MkwUc9$h3&87mv9biEvC7M2NM0HC0UW5_+)`N1cC`s?&7M zjvDUm)g@omF?@RR1zJk)yeK8s@3@`QbZSa^;eH2gIf!KQ4oX;wqs~tDA_`x@0U zgHo)`Rj1G`r4>$kJ71usoUT`@#RMsj9TMdobmt(D$}SE``0D2uy_}CgDe(I{PDKt0 zsB}iO%%Bu&ixEwZDv0PgofdPH_OZ$Gk$~UVDN4XkhkUfoi28j5N-H5P`GPn-FPK(H?+Kih@Z~;AX0B3IjrA*_Ab9)Vv^ST2!yzg#s%`yw|~$@ z96MmlVXOm3=ap{7(|sRA4AVbFNnxY|qdvyhib=|tqz|X-J_0$8#|0Sm>GJ;o00960 l*&TfN00006NklrS literal 0 HcmV?d00001 diff --git a/web/setup.css b/web/setup.css index 6f30a708..2fff248b 100644 --- a/web/setup.css +++ b/web/setup.css @@ -24,6 +24,9 @@ .fv-setup .fv-guide-replay-row{display:flex;align-items:center;justify-content:space-between;gap:16px} .fv-setup .fv-guide-replay-row h3{margin:0} .fv-setup .fv-guide-replay-actions{display:flex;align-items:center;gap:16px} +.fv-setup .fv-guide-howtos{display:flex;flex-direction:column;align-items:stretch;gap:4px;margin-top:10px;width:100%} +.fv-setup .fv-guide-howtos [role=menuitem]{width:100%;justify-content:flex-start;text-align:left} +.fv-setup .fv-guide-howtos[hidden]{display:none} .fv-setup .fv-guide-replay [role=status]{margin-top:8px} /* Sections: one card each; details use the Studio's chevron, not the UA marker. */ diff --git a/web/studio.js b/web/studio.js index 33aaacdb..11744937 100644 --- a/web/studio.js +++ b/web/studio.js @@ -739,7 +739,7 @@ export function openStudio(node, {extendFrom = null} = {}) { resolutionSelect.append(mp, resolutionValue, icon('chevron')); const legacyOption = [...mp.options].find(o => Number(o.value) === 1); mp.disabled = dimensionsLinked; - const seconds = el('input'); seconds.type = 'number'; seconds.required = true; seconds.min = '1.625'; seconds.step = 'any'; seconds.value = savedSeconds(node, value(node, 'seconds')); seconds.disabled = linked(node, 'seconds'); seconds.setAttribute('aria-label', t('Duration in seconds', '时长(秒)')); + const seconds = el('input'); seconds.type = 'number'; seconds.required = true; seconds.min = '1.625'; seconds.step = 'any'; seconds.value = savedSeconds(node, value(node, 'seconds')); seconds.disabled = linked(node, 'seconds'); seconds.setAttribute('aria-label', t('Duration in seconds', '时长(秒)')); seconds.dataset.fvGuide = 'seconds'; const fields = el('div', null, 'fv-fields'); fields.append(field(t('Resolution', '清晰度'), resolutionSelect), field(t('Duration · seconds', '时长 · 秒'), seconds)); canvas.append(fields); const canvasNote = el('div', null, 'fv-canvas-note'), dimensions = el('span'), duration = el('span'); canvasNote.append(dimensions, duration); canvas.append(canvasNote); const rangeMount = el('div'); canvas.append(rangeMount); From 9ee07ea4a20c910617c3fe36d6fadc2ff558a112 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 21:07:36 +0000 Subject: [PATCH 13/21] Add an offline bilingual tutorial book with a Settings entry --- MANIFEST.in | 1 + ...77\347\224\250\350\257\264\346\230\216.md" | 2 +- freevideo_engine/desktop_runtime.py | 5 +- web/first_run_guide.js | 12 +- web/theme.css | 2 +- web/tutorial/editor-extend-refs-1.webp | Bin 0 -> 33566 bytes web/tutorial/editor-extend-refs-2.webp | Bin 0 -> 35682 bytes web/tutorial/long-video-1.webp | Bin 0 -> 14200 bytes web/tutorial/long-video-2.webp | Bin 0 -> 15322 bytes web/tutorial/reference-video-1.webp | Bin 0 -> 10882 bytes web/tutorial/reference-video-2.webp | Bin 0 -> 11990 bytes web/tutorial_book.css | 56 +++++++ web/tutorial_book.js | 146 ++++++++++++++++++ web/tutorial_book_content.js | 58 +++++++ 14 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 web/tutorial/editor-extend-refs-1.webp create mode 100644 web/tutorial/editor-extend-refs-2.webp create mode 100644 web/tutorial/long-video-1.webp create mode 100644 web/tutorial/long-video-2.webp create mode 100644 web/tutorial/reference-video-1.webp create mode 100644 web/tutorial/reference-video-2.webp create mode 100644 web/tutorial_book.css create mode 100644 web/tutorial_book.js create mode 100644 web/tutorial_book_content.js diff --git a/MANIFEST.in b/MANIFEST.in index 04d51b02..357098c5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,6 +8,7 @@ include web/vendor/three/LICENSE recursive-include web/assets *.svg *.png *.webp recursive-include web/howto *.png recursive-include web/fonts *.woff2 +recursive-include web/tutorial *.webp *.mp4 recursive-include freevideo_engine/assets *.png *.ico recursive-include freevideo_engine/launcher *.qml recursive-include freevideo_engine/launcher/licenses *.txt 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 3e00fb3d..8c2552c0 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" @@ -48,6 +48,6 @@ - 两种做法一样,一条视频最多 5 段(第一段加 4 次接着生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。接得越多,衔接处越容易出现变化,能切镜头的地方建议直接切镜头,用对应的参考图开始新的一段。 - 接着生成使用轻量档,需要 NVIDIA 显卡,也不能和“继续二采”同时使用。 -- 单次生成的最长时长随画幅和显卡而不同,在创作面板的“范围”里可以查看每种尺寸能生成多长、大约需要多久。 +- 单次生成的最长时长随画幅和显卡而不同,在创作面板的“可生成范围”里可以查看每种尺寸能生成多长、大约需要多久。 - 分段生成同样需要 NVIDIA 显卡,Mac 上不支持。 - 分段生成过程中取消,只会停止当前这一段,已经完成的段落会保留,之后可以从第一个未完成的段落继续。 diff --git a/freevideo_engine/desktop_runtime.py b/freevideo_engine/desktop_runtime.py index f76241c9..aa62606c 100644 --- a/freevideo_engine/desktop_runtime.py +++ b/freevideo_engine/desktop_runtime.py @@ -61,8 +61,9 @@ def source_files(root): files = [root / name for name in names] files += [p for folder in ('freevideo_engine', 'constraints', 'web', 'example_workflows') for p in (root / folder).rglob('*') - if p.is_file() and p.suffix in ('.py', '.json', '.txt', '.ps1', '.sh', '.js', '.css', '.svg', '.png', '.ico', '.qml', - '.woff2', '.webp')] + if p.is_file() and (p.suffix in ('.py', '.json', '.txt', '.ps1', '.sh', '.js', '.css', '.svg', '.png', '.ico', '.qml', + '.woff2', '.webp') + or folder == 'web' and p.parent.name == 'tutorial' and p.suffix == '.mp4')] return sorted(files) diff --git a/web/first_run_guide.js b/web/first_run_guide.js index 85affe19..ca669c84 100644 --- a/web/first_run_guide.js +++ b/web/first_run_guide.js @@ -150,6 +150,16 @@ export function createGuideReplayEntry({dialog, cn, openWorkspace, replay = show const manual = el('button', text('manual')); manual.type = 'button'; manual.setAttribute('aria-haspopup', 'menu'); manual.setAttribute('aria-expanded', 'false'); const menu = el('div', null, 'fv-guide-howtos'); menu.setAttribute('role', 'menu'); menu.hidden = true; const closeMenu = () => { menu.hidden = true; manual.setAttribute('aria-expanded', 'false'); }; + // The last row opens the whole tutorial book; Settings closes first so a how-to started from the book plays over the workspace. + const openBook = el('button', cn ? '教程' : 'Tutorial'); openBook.type = 'button'; openBook.setAttribute('role', 'menuitem'); + openBook.onclick = async () => { + closeMenu(); + try { + const {openTutorialBook} = await import('./tutorial_book.js'); + await new Promise(resolve => { dialog.addEventListener('close', resolve, {once: true}); dialog.close(); }); + openTutorialBook(); + } catch { status.textContent = text('unavailable'); status.hidden = false; } + }; manual.onclick = async () => { if (!menu.hidden) { closeMenu(); return; } try { @@ -165,7 +175,7 @@ export function createGuideReplayEntry({dialog, cn, openWorkspace, replay = show } catch { status.textContent = text('unavailable'); status.hidden = false; } }; return choice; - })); + }), openBook); menu.hidden = false; manual.setAttribute('aria-expanded', 'true'); } catch { status.textContent = text('unavailable'); status.hidden = false; } }; diff --git a/web/theme.css b/web/theme.css index 5982442c..17ec71a9 100644 --- a/web/theme.css +++ b/web/theme.css @@ -1,7 +1,7 @@ /* One token table for the web views, mirroring freevideo_engine/branding.py so a control looks the same in the launcher and in ComfyUI. Scope the tokens to our own views; never change ComfyUI's theme. */ -:where(.fv-studio,.fv-setup,.fv-panel,.fv-generation-progress,.fv-view-navigation,.fv-prompt-guide,.fv-failure-dialog,.fv-reference-picker,.fv-release-notes,.fv-image-editor,.fv-cast-dialog){ +:where(.fv-studio,.fv-setup,.fv-panel,.fv-generation-progress,.fv-view-navigation,.fv-prompt-guide,.fv-failure-dialog,.fv-reference-picker,.fv-release-notes,.fv-image-editor,.fv-cast-dialog,.fv-tutorial-book){ /* Surfaces, from the backdrop up to a hovered control; mirrors branding.py. */ --fv-bg:#111720;--fv-canvas:#161e29;--fv-card:#1a222e;--fv-raised:#212a37;--fv-hover:#2a3341; --fv-border:#2a3443;--fv-sheen:#35404e; diff --git a/web/tutorial/editor-extend-refs-1.webp b/web/tutorial/editor-extend-refs-1.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b2536b2d4bb92fecf999c659e41795ef0d085f4 GIT binary patch literal 33566 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 literal 0 HcmV?d00001 diff --git a/web/tutorial/editor-extend-refs-2.webp b/web/tutorial/editor-extend-refs-2.webp new file mode 100644 index 0000000000000000000000000000000000000000..efedd788312209b827dfe205c3387916291dca08 GIT binary patch literal 35682 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 { + const node = document.createElement(tag); + if (cls) node.className = cls; + if (text != null) node.textContent = text; + return node; +}; +const language = () => { + const choice = new URLSearchParams(location.search).get('freevideo_lang'); + return choice === 'zh' || (choice !== 'en' && navigator.language?.toLowerCase().startsWith('zh')) ? 'zh' : 'en'; +}; + +// Media paths are filenames under this module's bundled tutorial directory only. +// Both images (including animated WebP) and short silent MP4 loops are supported. +function mediaElement(media, lang, title) { + const file = typeof media.src === 'string' ? media.src : media.src[lang]; + if (!/^[a-z0-9][a-z0-9_-]*\.(webp|png|mp4)$/i.test(file)) throw new Error('Invalid tutorial media filename'); + const node = el(media.type === 'video' ? 'video' : 'img', 'fv-book-media'); + node.src = new URL(`./tutorial/${file}`, import.meta.url).href; + if (media.type === 'video') { + node.muted = true; + node.loop = true; + node.autoplay = true; + node.playsInline = true; + node.controls = true; + node.setAttribute('aria-label', media.alt?.[lang] || title); + if (matchMedia('(prefers-reduced-motion: reduce)').matches) node.autoplay = false; + } else { + node.alt = media.alt?.[lang] || title; + node.decoding = 'async'; + } + return node; +} + +let current = null; + +/** Open the bundled manual; chapter is one of the ids in tutorial_book_content.js. */ +export function openTutorialBook({chapter = TUTORIAL_CHAPTERS[0].id} = {}) { + if (current?.dialog.isConnected) { + current.select(chapter); + return current.dialog; + } + if (!document.querySelector('link[href*="theme.css"]')) { + const theme = el('link'); theme.rel = 'stylesheet'; theme.href = new URL('./theme.css', import.meta.url).href; + document.head.append(theme); + } + if (!document.querySelector('link[data-fv-tutorial-book]')) { + const css = el('link'); css.rel = 'stylesheet'; css.dataset.fvTutorialBook = ''; + css.href = new URL('./tutorial_book.css', import.meta.url).href; + document.head.append(css); + } + const lang = language(), text = COPY[lang], restore = document.activeElement; + const dialog = el('dialog', 'fv-tutorial-book'); + dialog.lang = lang === 'zh' ? 'zh-CN' : 'en'; + dialog.setAttribute('aria-labelledby', 'fv-book-title'); + const header = el('header', 'fv-book-header'), title = el('h1', null, text.book); + title.id = 'fv-book-title'; + const close = el('button', 'fv-book-close', text.close); close.type = 'button'; + header.append(title, close); + const layout = el('div', 'fv-book-layout'), nav = el('nav', 'fv-book-nav'); + nav.setAttribute('aria-label', text.contents); + const mobile = el('label', 'fv-book-picker', text.contents), picker = el('select'); + picker.setAttribute('aria-label', text.contents); mobile.append(picker); + const article = el('article', 'fv-book-article'); article.tabIndex = 0; + const navButtons = new Map(); + let start = null; + const syncHowTo = () => { + if (!start) return; + const button = start, id = dialog.dataset.chapter; + button.disabled = typeof window.freevideoHowTo?.start !== 'function'; + button.hidden = button.disabled; + // A walk-through that this device cannot do (no longer videos in parts) is not offered. + if (!button.disabled) Promise.resolve(window.freevideoHowTo.available?.(id)).then(ok => { + if (ok === false && start === button) { button.disabled = true; button.hidden = true; } + }).catch(() => {}); + }; + function select(id) { + const item = TUTORIAL_CHAPTERS.find(c => c.id === id) || TUTORIAL_CHAPTERS[0]; + dialog.dataset.chapter = item.id; + picker.value = item.id; + for (const [key, button] of navButtons) { + if (key === item.id) button.setAttribute('aria-current', 'page'); + else button.removeAttribute('aria-current'); + } + article.querySelectorAll('video').forEach(video => video.pause()); + article.replaceChildren(el('h2', 'fv-book-chapter-title', item.title[lang])); + start = null; + article.append(el('p', 'fv-book-summary', item.summary[lang]), mediaElement(item.media, lang, item.title[lang])); + const steps = el('ol', 'fv-book-steps'); + item.steps[lang].forEach(step => steps.append(el('li', null, step))); + const limits = el('section', 'fv-book-limits'), list = el('ul'); + limits.append(el('h3', null, text.limits), list); + item.limits[lang].forEach(limit => list.append(el('li', null, limit))); + start = el('button', 'fv-book-start', text.start); start.type = 'button'; + start.onclick = () => { + syncHowTo(); + if (start.disabled) return; + dialog.close(); + window.freevideoHowTo?.start(item.id); + }; + article.append(steps, start, limits); + syncHowTo(); + article.scrollTop = 0; + } + for (const item of TUTORIAL_CHAPTERS) { + const button = el('button', 'fv-book-chapter', item.title[lang]); button.type = 'button'; + button.dataset.chapter = item.id; + button.onclick = () => select(item.id); + navButtons.set(item.id, button); nav.append(button); + const option = el('option', null, item.title[lang]); + option.value = item.id; picker.append(option); + } + picker.onchange = () => select(picker.value); + close.onclick = () => dialog.close(); + // Stop workspace shortcuts while reading, including Control+Enter and Delete. + for (const name of ['keydown', 'keypress', 'keyup']) dialog.addEventListener(name, event => event.stopPropagation()); + dialog.addEventListener('close', () => { + dialog.querySelectorAll('video').forEach(video => video.pause()); + window.removeEventListener('focus', syncHowTo); + window.removeEventListener('freevideo-howto-ready', syncHowTo); + dialog.remove(); current = null; + if (restore?.isConnected) restore.focus({preventScroll: true}); + }, {once: true}); + window.addEventListener('focus', syncHowTo); + window.addEventListener('freevideo-howto-ready', syncHowTo); + layout.append(nav, article); dialog.append(header, mobile, layout); + document.body.append(dialog); + current = {dialog, select}; select(chapter); dialog.showModal(); close.focus(); + return dialog; +} + +/** Append an entry to a settings row, or return it for the caller to place. + * Example: mountBookEntry({container: row, chapter: 'reference-video'}). + * Callers that own another modal may close it in beforeOpen. + */ +export function mountBookEntry({container, chapter, beforeOpen} = {}) { + const button = el('button', 'fv-book-entry', COPY[language()].book); button.type = 'button'; + button.onclick = () => { beforeOpen?.(); openTutorialBook({chapter}); }; + container?.append(button); + return button; +} diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js new file mode 100644 index 00000000..5dec95f4 --- /dev/null +++ b/web/tutorial_book_content.js @@ -0,0 +1,58 @@ +// Bundled tutorial copy, a short form of docs/zh-CN/使用说明.md; keep them consistent. +// Only finished chapters are listed. Add a chapter here when it is written, with its picture row in scripts/tutorial_pictures.json. +export const TUTORIAL_CHAPTERS = [ + { + id: 'reference-video', + title: {zh: '参考图生成视频', en: 'Make a video from reference images'}, + summary: {zh: '让参考图里的人物、物品或场景出现在视频中。', en: 'Use people, objects or places from reference images in a video.'}, + media: { + type: 'image', + src: {zh: 'reference-video-1.webp', en: 'reference-video-2.webp'}, + alt: {zh: '创作面板:素材区域和参考图已用红框标出。', en: 'The creation panel, with the Media section and a reference image outlined in red.'}, + }, + steps: { + zh: ['在创作面板的“素材”里添加图片,每张可选作参考、首帧或尾帧。', '在描述里输入 @ 选一张图,并说明它在画面里做什么,例如“ 里的女孩在海边奔跑”。', '点“生成视频”。'], + en: ['Add images under "Media" in the creation panel and set each one as a reference, first frame, or last frame.', 'Type @ in the prompt and choose an image, then say what it does in the scene, for example "The girl in runs along the beach".', 'Choose "Generate video".'], + }, + limits: { + zh: ['参考图生成还在试验中,人物外观不一定每次一致;换一张更清晰的正面图再试。', '首帧和尾帧可以一起用,但暂时不能和参考图一起用。', '首帧、尾帧会按画幅居中裁切;参考图保持原比例。'], + en: ['Reference generation is experimental, so a character may look different between runs. A clearer, front-facing image helps.', 'First and last frames can be used together, but not with reference images yet.', 'First and last frames are center-cropped to the frame shape. Reference images keep their proportions.'], + }, + }, + { + id: 'long-video', + title: {zh: '连续的长视频', en: 'Make a longer video'}, + summary: {zh: '填写更长的时长自动分段生成,也可以逐段接着生成。', en: 'Enter a longer duration to generate in parts, or continue one part at a time.'}, + media: { + type: 'image', + src: {zh: 'long-video-1.webp', en: 'long-video-2.webp'}, + alt: {zh: '创作面板:时长输入框和“可生成范围”入口已用红框标出。', en: 'The creation panel, with the duration field and "Available sizes and lengths" control outlined in red.'}, + }, + steps: { + zh: ['把时长填长一些,会自动分段生成;“可生成范围”里可以查看每种尺寸能生成多长、大约要多久。', '点“生成视频”。', '想接着做下去,在生成好的视频上点“接着生成”。', '也可以到“剪辑视图”里一段段接。'], + 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".', 'To carry on from a finished video, click "Extend" on its result.', 'Or add parts one by one in "Edit".'], + }, + limits: { + zh: ['一条视频最多 5 段,创作面板里时长最多填 60 秒。', '分段生成和接着生成需要 NVIDIA 显卡,Mac 上不支持。', '接得越多,衔接处越容易变化;能切镜头的地方,直接切镜头开始新的一段。'], + en: ['A video has at most 5 parts, and the creation panel accepts up to 60 seconds.', 'Generating in parts and Extend need an NVIDIA graphics card and are not available on 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.'], + }, + }, + { + id: 'editor-extend-refs', + title: {zh: '在剪辑视图延长时加参考图', en: 'Add reference images when extending in Edit'}, + summary: {zh: '用参考图引导接着生成的那一段。', en: 'Use reference images to guide the next part.'}, + media: { + type: 'image', + src: {zh: 'editor-extend-refs-1.webp', en: 'editor-extend-refs-2.webp'}, + alt: {zh: '剪辑视图:选中的接续段和描述框旁的参考图入口已用红框标出。', en: 'The Edit view, with the selected continuation and the reference image control beside the prompt outlined in red.'}, + }, + steps: { + zh: ['选中要接着生成的那一段。', '点描述框旁的 +,上传或生成图片,最多 3 张。', '写一句描述,再点“生成”。'], + 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 张参考图,不用首帧和尾帧。', '剪辑视图里只能加图片;参考视频和音频在创作面板的“素材”里添加。', '同样最多 5 段,需要 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 5-part limit applies, and an NVIDIA graphics card is required. Not available on Mac.'], + }, + }, +]; From 8df550a00f3212e285761da4e5ef3fbce21e0657 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 21:07:43 +0000 Subject: [PATCH 14/21] Copy sweep for guide, how-to and tutorial strings; reference video and audio step Copy sweep for guide, how-to and tutorial strings; reference video and audio step; Enter a prompt first --- ...77\347\224\250\350\257\264\346\230\216.md" | 24 +++++++++---------- web/first_run_guide.js | 6 ++--- web/howto.js | 16 +++++++------ web/tutorial_book.css | 2 +- web/tutorial_book.js | 4 ++-- web/tutorial_book_content.js | 20 ++++++++-------- 6 files changed, 37 insertions(+), 35 deletions(-) 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 8c2552c0..79955d80 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" @@ -5,13 +5,13 @@ ## 参考图生成视频 1. 打开创作面板,展开“素材”。 -2. 点“添加”,选择图片。上传的每张图片可以指定用途:首帧、尾帧或参考。要让画面里出现这张图里的人物、物品或场景,选“参考”。 -3. 在描述里输入 `@`,从列表里选一张素材,描述里会出现 `` 这样的标记。视频和音频素材对应 `