From 3a4189f516cfd565b8e83c1704187a48216fd167 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 07:16:49 +0000 Subject: [PATCH 01/10] Check only the selected components' paths when installing image-only The kernels step of an image-only install no longer requires the video model directories; a video install still requires them. --- freevideo_engine/agent/schema.py | 6 +++--- freevideo_engine/bootstrap.py | 3 ++- freevideo_engine/cli.py | 4 +++- freevideo_engine/doctor.py | 6 ++++++ freevideo_engine/macos_doctor.py | 8 +++++++- freevideo_engine/macos_generate.py | 3 ++- freevideo_engine/macos_setup.py | 3 ++- web/freevideo.js | 2 +- web/generation_progress.js | 6 ++++-- web/howto.js | 4 ++-- web/image_library.css | 5 ++--- web/image_library.js | 9 +++++++-- web/studio.css | 4 +++- web/studio.js | 2 +- web/tutorial_book_content.js | 8 ++++---- web/updates.css | 13 +++++++++++-- web/updates.js | 9 ++------- web/view_navigation.js | 4 ++-- 18 files changed, 64 insertions(+), 35 deletions(-) diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 16dcf72e..ea16fad3 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -193,8 +193,8 @@ def generation_media(*, local=False, enhance=False): def generate(*, local=False): return obj(dict(kind={'const': 'generate', 'type': 'string', 'default': 'generate'}, prompt={'oneOf': [string(20000), obj({'from_job': JOB}, ('from_job',))]}, - width=number(256, 4096, integer=True, multipleOf=32, default=1344), - height=number(256, 4096, integer=True, multipleOf=32, default=768), + width=number(256, 4096, integer=True, multipleOf=32, description='A multiple of 32. For a size that is not, use the nearest multiple on each side (1000 -> 992).', default=1344), + height=number(256, 4096, integer=True, multipleOf=32, description='A multiple of 32. For a size that is not, use the nearest multiple on each side (1000 -> 992).', default=768), aspect=string(7, pattern=r'^[1-9][0-9]{0,2}:[1-9][0-9]{0,2}$', examples=sorted(ASPECTS)), megapixels=number(.1, 16, default=.98), seconds=number(1.625, 60, default=10), seed=dict(number(0, 2**53 - 1, integer=True), type=['integer', 'null'], default=None), @@ -995,7 +995,7 @@ def image_schema(name): 'list': ('GET', '/projects', 'List editing projects shared with the editor page. Then get one to read its revision and clip IDs.'), 'get': ('GET', '/projects/{id}', 'Read a timeline and authenticated source URLs. Read its revision and clip IDs before editing.'), 'create': ('POST', '/projects', 'Create an editor timeline. Send {"project": {"name": "..."}}; the rest of an empty timeline is filled in.'), - 'write': ('PUT', '/projects/{id}', 'Replace a timeline. First get the project and preserve its id and revision; recompute from error.project on project_changed.'), + 'write': ('PUT', '/projects/{id}', 'Replace the whole timeline. To remove, insert, move or trim clips use freevideo_editor_operations instead. First get the project and preserve its id and revision; recompute from error.project on project_changed.'), 'operations': ('POST', '/projects/{id}/operations', 'Apply 1-64 timeline edits atomically and save once. First get revision and clip IDs; on project_changed recompute from error.project, and on clip_busy wait for generation to finish.'), 'generate': ('POST', '/projects/{id}/generate', 'Make new picture and sound for selected clips or a frame range, as the page Generate does. First get revision and clip IDs; the parts are reserved at once and fill in as the returned job finishes.'), 'export': ('POST', '/projects/{id}/export', 'Render one saved revision to an MP4 in the saved videos. First get the revision; wait for the returned job, then read its result video.'), diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 7ddef946..e4aa401d 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -2240,7 +2240,8 @@ def check_kernels(self, python, kernel_report): receipt = (reusable_kernel_receipt(cached_kernel, hardware_data) if isinstance(hardware_data, dict) else None) if receipt is None: - self.command('kernels', [python, '-m', 'freevideo_engine', 'doctor', '--probe', '--require-paths', '--out', kernel_report]) + self.command('kernels', [python, '-m', 'freevideo_engine', 'doctor', '--probe', '--require-paths', + *(['--without-video-models'] if self.plan.get('only') == 'image' else []), '--out', kernel_report]) else: save(kernel_report, receipt) self.ui.event('setup_cache', key='kernels', detail='Reused matching GPU kernel checks') diff --git a/freevideo_engine/cli.py b/freevideo_engine/cli.py index 7e15b6b3..5c9eec28 100644 --- a/freevideo_engine/cli.py +++ b/freevideo_engine/cli.py @@ -73,6 +73,7 @@ def main(): doctor = sub.add_parser('doctor', help='Check paths, versions and installed attention kernels') doctor.add_argument('--probe', action='store_true', help='Execute small kernels in isolated processes') doctor.add_argument('--require-paths', action='store_true', help='Fail if model or source directories are missing') + doctor.add_argument('--without-video-models', action='store_true', help='With --require-paths: an image-only installation has no video model directories') doctor.add_argument('--out', type=Path, help='Save the complete kernel report, including optional failures') sub.add_parser('diagnose', help='Collect bounded local diagnostics, even after a failed setup') sub.add_parser('optimize', help='Reuse a test report and validate bounded local tuning') @@ -202,7 +203,8 @@ def main(): write_json(result) if args.probe and not result['ready']: raise SystemExit(1) - if args.require_paths and not all(row['exists'] for row in result['paths'].values()): + from .doctor import missing_required_paths + if args.require_paths and missing_required_paths(result, video_models=not args.without_video_models): raise SystemExit(1) elif args.command == 'setup': from .install import install diff --git a/freevideo_engine/doctor.py b/freevideo_engine/doctor.py index ef3c0b63..c10295d2 100644 --- a/freevideo_engine/doctor.py +++ b/freevideo_engine/doctor.py @@ -11,6 +11,12 @@ from . import processes, subprocess_output +def missing_required_paths(result, *, video_models=True): + """Names of required directories that do not exist; an image-only installation has no video model to check.""" + skipped = () if video_models else ('base', 'checkpoint') + return [k for k, row in result['paths'].items() if k not in skipped and not row['exists']] + + def doctor(*, probe=False, hardware=None, backends=None, progress=None): if progress is None and os.environ.get('FREEVIDEO_INSTALL_PROGRESS') == '1': from .install_activity import step diff --git a/freevideo_engine/macos_doctor.py b/freevideo_engine/macos_doctor.py index 480d2f53..95466b18 100644 --- a/freevideo_engine/macos_doctor.py +++ b/freevideo_engine/macos_doctor.py @@ -90,10 +90,16 @@ def doctor(*, probe=False): return result +def missing_required_paths(result, *, video_models=True): + skipped = () if video_models else ('base', 'checkpoint') + return [k for k, row in result['paths'].items() if k not in skipped and not row['exists']] + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--probe', action='store_true') parser.add_argument('--require-paths', action='store_true') + parser.add_argument('--without-video-models', action='store_true') parser.add_argument('--out', type=Path) args = parser.parse_args() result = doctor(probe=args.probe) @@ -102,7 +108,7 @@ def main(): save(args.out, result) print(json.dumps(result, indent=2)) return int((args.probe and not result['ready']) or - (args.require_paths and not all(row['exists'] for row in result['paths'].values()))) + (args.require_paths and missing_required_paths(result, video_models=not args.without_video_models))) if __name__ == '__main__': diff --git a/freevideo_engine/macos_generate.py b/freevideo_engine/macos_generate.py index 260efa14..f98c06b7 100644 --- a/freevideo_engine/macos_generate.py +++ b/freevideo_engine/macos_generate.py @@ -438,8 +438,9 @@ def dispatch(args): from .macos_doctor import doctor result = doctor(probe=args.probe) write_json(result, args.out) + from .macos_doctor import missing_required_paths if (args.probe and not result['ready']) or (args.require_paths and - not all(row['exists'] for row in result['paths'].values())): + missing_required_paths(result, video_models=not args.without_video_models)): raise SystemExit(1) elif args.command == 'plan': write_json(policy(args), args.out) diff --git a/freevideo_engine/macos_setup.py b/freevideo_engine/macos_setup.py index ab7f10b1..bc9c8b6c 100644 --- a/freevideo_engine/macos_setup.py +++ b/freevideo_engine/macos_setup.py @@ -70,7 +70,8 @@ def check_kernels(self, python, kernel_report): # A cheap native execution check is repeated on repair. Do not reuse # a CUDA receipt or claim a full model benchmark from this probe. self.command('kernels', [python, '-m', 'freevideo_engine.macos_doctor', - '--probe', '--require-paths', '--out', kernel_report]) + '--probe', '--require-paths', *(['--without-video-models'] if self.plan.get('only') == 'image' else []), + '--out', kernel_report]) result = json.loads(kernel_report.read_text(encoding='utf-8')) if not result.get('ready') or result.get('device_backend') != 'mps': raise RuntimeError('Native GPU checks did not complete; see the retained report') diff --git a/web/freevideo.js b/web/freevideo.js index f74a00a7..61d632d5 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -150,7 +150,7 @@ function mediaPanel(node, mount = null) { const cards = el("div"), connections = el("div", undefined, "fv-connected"); const picker = el("input"); picker.type = "file"; picker.multiple = true; picker.accept = acceptList(["image", "video", "audio"]); picker.hidden = true; - const add = button(text("Upload files", "上传文件"), text("Upload images, video or audio", "上传图片、视频或音频"), () => { + const add = button(text("Add", "添加"), text("Add images, video or audio", "添加图片、视频或音频"), () => { if (libraryMenu?.box.isConnected) { libraryMenu.close(); return; } libraryMenu = openReferenceMenu({api, viewURL, anchor: add, host: panel, onComputer: () => picker.click(), onPick: (file, id) => upload([file]).then(() => { picked.add(id); libraryMenu?.refresh(); }), diff --git a/web/generation_progress.js b/web/generation_progress.js index 4bb30521..99428364 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -233,11 +233,13 @@ function activityLabel(message, t, plan = {}, secondPass = false, references = f function retryText(retry, t) { const reason = {gpu_oom: ['Out of GPU memory', '显存不足'], ram_pressure: ['Out of RAM', '内存不足'], - unified_memory: ['Not enough memory', '内存不够']}[retry.kind] + unified_memory: ['Out of RAM', '内存不足']}[retry.kind] || ['Previous attempt did not finish', '上一次尝试没有完成']; if (retry.reuse_sampling === true) return t(`${reason[0]}; the generated result is kept, and a retry is in progress.`, `${reason[1]},已保留生成结果,正在重试。`); - return t(`${reason[0]}; adjusting settings and retrying.`, `${reason[1]},正在调整设置后重试。`); + const fallback = !['gpu_oom', 'ram_pressure', 'unified_memory'].includes(retry.kind); + return fallback ? t(`${reason[0]}; retrying in another mode.`, `${reason[1]},正在换一种运行方式重试。`) + : t(`${reason[0]}; retrying in a mode that uses less memory.`, `${reason[1]},正在换一种更省${retry.kind === 'gpu_oom' ? '显存' : '内存'}的运行方式重试。`); } function trimStatus(rows, t) { diff --git a/web/howto.js b/web/howto.js index 1aa81364..4349dea2 100644 --- a/web/howto.js +++ b/web/howto.js @@ -26,8 +26,8 @@ const HOWTOS = [ 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: 'media-av', target: mediaSection, en: 'Videos and audio can be references too: add them with "Upload files" and mention them in the prompt with @.', - zh: '视频和音频也可作为参考:点“上\u2060传\u2060文\u2060件”添加,在描述中输入 @ 引用。'}, + {id: 'media-av', target: mediaSection, en: 'Videos and audio can be references too: add them the same way with "Add" and mention them in the prompt with @.', + zh: '视频和音频也可作为参考:同样点“添\u2060加”,在描述中输入 @ 引用。'}, {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: [ diff --git a/web/image_library.css b/web/image_library.css index b779ab3b..3bdddb29 100644 --- a/web/image_library.css +++ b/web/image_library.css @@ -1,6 +1,5 @@ .fv-image-menu{position:fixed;z-index:60;box-sizing:border-box;padding:8px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-raised);box-shadow:0 8px 24px rgba(0,0,0,.35);display:flex;flex-direction:column;gap:6px} -.fv-image-menu-item{min-height:32px;padding:0 8px;border:0;background:transparent;color:var(--fv-ink);font-size:var(--fv-body);text-align:left;cursor:pointer;border-radius:var(--fv-r-sm)}.fv-image-menu-item:hover{background:var(--fv-hover,rgba(255,255,255,.06))} +.fv-image-menu .fv-image-menu-item{display:flex;align-items:center;justify-content:flex-start;width:100%;min-height:32px;padding:6px 8px;border:0;border-radius:6px;background:transparent;color:var(--fv-ink);font-size:var(--fv-body);text-align:left;cursor:pointer}.fv-image-menu .fv-image-menu-item:hover{background:var(--fv-hover,rgba(255,255,255,.06))} .fv-image-menu-label{padding:0 8px;color:var(--fv-muted);font-size:var(--fv-body)}.fv-image-menu-label[hidden],.fv-image-menu-grid[hidden]{display:none} .fv-image-menu-grid{display:grid;grid-template-columns:repeat(4,72px);gap:6px;max-height:228px;overflow-y:auto;scrollbar-width:thin} -.fv-image-menu-cell{width:72px;height:72px;padding:0;border:0;border-radius:var(--fv-r-sm);overflow:hidden;background:transparent;cursor:pointer}.fv-image-menu-cell img{width:100%;height:100%;object-fit:cover;display:block}.fv-image-menu-cell:disabled{opacity:.35;cursor:default} -@media (max-width:420px){.fv-image-menu-grid{grid-template-columns:repeat(3,minmax(44px,1fr))}.fv-image-menu-cell{width:auto;height:auto;aspect-ratio:1}} +.fv-image-menu-cell{width:72px;height:72px;padding:0;border:0;border-radius:var(--fv-r-sm);overflow:hidden;background:transparent;cursor:pointer}.fv-image-menu-cell img{width:100%;height:100%;object-fit:cover;display:block}.fv-image-menu-cell:disabled{opacity:.4;cursor:default} diff --git a/web/image_library.js b/web/image_library.js index 919e6c70..0c86ba13 100644 --- a/web/image_library.js +++ b/web/image_library.js @@ -46,8 +46,13 @@ export function openReferenceMenu({api, viewURL, anchor, host = document.body, o const label = el('div', 'fv-image-menu-label', t('Images', '图片')), grid = el('div', 'fv-image-menu-grid'); label.hidden = grid.hidden = true; box.append(computer, label, grid); host.append(box); const place = () => { - const r = (typeof anchor === 'function' ? anchor() : anchor).getBoundingClientRect(), width = Math.min(innerWidth - 32, 4 * 72 + 3 * 6 + 16); - box.style.cssText = `left:${Math.max(16, Math.min(r.left, innerWidth - width - 16))}px;top:${Math.min(r.bottom + 6, innerHeight - 160)}px;width:${width}px`; + const r = (typeof anchor === 'function' ? anchor() : anchor).getBoundingClientRect(), narrow = innerWidth <= 420; + const width = narrow ? innerWidth - 32 : Math.min(innerWidth - 32, 4 * 72 + 3 * 6 + 16); + const left = narrow ? 16 : Math.max(16, Math.min(r.left, innerWidth - width - 16)); + box.style.cssText = `left:${left}px;top:0;width:${width}px`; + const height = box.offsetHeight, below = r.bottom + 6; + const top = below + height <= innerHeight - 16 ? below : r.top - 6 - height >= 16 ? r.top - 6 - height : Math.max(16, innerHeight - 16 - height); + box.style.top = top + 'px'; }; const close = () => { document.removeEventListener('pointerdown', away, true); document.removeEventListener('keydown', key, true); removeEventListener('resize', place); box.remove(); }; const away = event => { if (!box.contains(event.target) && !(typeof anchor === 'function' ? anchor() : anchor).contains(event.target)) close(); }; diff --git a/web/studio.css b/web/studio.css index a71da418..99a2cb56 100644 --- a/web/studio.css +++ b/web/studio.css @@ -14,7 +14,9 @@ .fv-studio .fv-header{height:76px;display:grid;grid-template-columns:1fr auto;align-items:center;padding:0 28px;border-bottom:1px solid var(--fv-border);gap:12px} .fv-brand{display:flex;min-width:0;align-items:center;gap:10px;font-size:var(--fv-strong);letter-spacing:-.5px;font-weight:var(--fv-semibold)} .fv-studio .fv-title{text-align:center}.fv-studio .fv-title strong{font-size:var(--fv-body);font-weight:var(--fv-medium)}.fv-studio .fv-header-actions{display:flex;justify-content:flex-end;align-items:center;gap:5px} -.fv-studio .fv-body{display:grid;grid-template-columns:420px minmax(0,1fr);height:calc(100% - 76px)} +.fv-studio[open]{display:flex;flex-direction:column}.fv-studio .fv-header{flex:none} +.fv-studio .fv-body{display:grid;grid-template-columns:420px minmax(0,1fr);flex:1 1 auto;min-height:0} +@media(max-width:680px){.fv-studio[open]{display:block}} .fv-studio .fv-controls{min-height:0;overflow:auto;padding:25px 26px 0;border-right:1px solid var(--fv-border);scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent} .fv-studio .fv-label{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;font-weight:var(--fv-semibold);color:var(--fv-ink);font-size:var(--fv-body)} .fv-studio .fv-muted{color:var(--fv-muted);font-size:var(--fv-micro);font-weight:var(--fv-regular)} diff --git a/web/studio.js b/web/studio.js index 4c441017..3f506aa9 100644 --- a/web/studio.js +++ b/web/studio.js @@ -690,7 +690,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const portNotice = createPortNotice(cn); if (portNotice) output.append(portNotice); const updateNotice = createUpdateNotice(cn); - output.append(updateNotice.element); cleanup.push(updateNotice.dispose); + header.after(updateNotice.element); cleanup.push(updateNotice.dispose); const section = (label, parent = controls) => { const wrap = el('section', null, 'fv-section'); wrap.append(el('div', label, 'fv-label')); parent.append(wrap); return wrap; }; const field = (name, input) => { const label = el('label', null, 'fv-field'); label.append(el('span', name), input); return label; }; const expand = label => { const d = el('details', null, 'fv-section'); d.append(el('summary', label, 'fv-section-title')); const content = el('div', null, 'fv-expand'); d.append(content); controls.append(d); cleanup.push(animateDetails(d)); return [d, content]; }; diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index 62a751ae..fbfcd90a 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -82,8 +82,8 @@ export const TUTORIAL_CHAPTERS = [ alt: {zh: '创作面板:素材区域中图片的用途下拉框已用红框标出。', en: 'The creation panel, with the image role selectors in the Media section outlined in red.'}, }, steps: { - zh: ['在“素材”中点“上传文件”,添加起始图片,并将它的用途下拉框选为“首帧”。', '需要结尾画面时,再添加一张图片并设为“尾⁠帧”。', '在描述中写明两张图之间发生了什么。', '点“生⁠成⁠视⁠频”。'], - en: ['Under "Media", choose "Upload files", add the starting image and set its role dropdown to "First frame".', 'For an ending picture, add another image and set it to "Last frame".', 'Describe what happens between the two images.', 'Choose "Generate video".'], + zh: ['在“素材”中点“添加”,选择起始图片,并将它的用途下拉框选为“首帧”。', '需要结尾画面时,再添加一张图片并设为“尾⁠帧”。', '在描述中写明两张图之间发生了什么。', '点“生⁠成⁠视⁠频”。'], + en: ['Under "Media", choose "Add", pick the starting image and set its role dropdown to "First frame".', 'For an ending picture, add another image and set it to "Last frame".', 'Describe what happens between the two images.', 'Choose "Generate video".'], }, example: { assets: [{file: 'anime-first.webp', role: 'first'}, {file: 'anime-last.webp', role: 'last'}], @@ -105,8 +105,8 @@ export const TUTORIAL_CHAPTERS = [ alt: {zh: '创作面板:素材区域和参考图已用红框标出。', en: 'The creation panel, with the Media section and a reference image outlined in red.'}, }, steps: { - zh: ['在创作面板的“素材”中添加图片,每张可设为参考、首帧或尾帧。', '在描述中输入 @ 选择图片,并写明它在画面中的作用,例如“ 里的女孩在海边奔跑”。', '视频和音频也可作为参考:点“上\u2060传\u2060文\u2060件”添加,在描述中输入 @ 引用。', '音频卡片上选“原样使用”时,成片直接使用这段声音,时长随音频而定;一次只能有一段音频原样使用。', '点“生⁠成⁠视⁠频”。'], - 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".', 'Videos and audio can be references too: add them with "Upload files" and mention them in the prompt with @.', 'On an audio card, choose "Use as is" to put that audio into the result unchanged. The duration follows the audio, and only one audio file can be used as is.', 'Choose "Generate video".'], + zh: ['在创作面板的“素材”中添加图片,每张可设为参考、首帧或尾帧。', '在描述中输入 @ 选择图片,并写明它在画面中的作用,例如“ 里的女孩在海边奔跑”。', '视频和音频也可作为参考:同样点“添\u2060加”,在描述中输入 @ 引用。', '音频卡片上选“原样使用”时,成片直接使用这段声音,时长随音频而定;一次只能有一段音频原样使用。', '点“生⁠成⁠视⁠频”。'], + 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".', 'Videos and audio can be references too: add them the same way with "Add" and mention them in the prompt with @.', 'On an audio card, choose "Use as is" to put that audio into the result unchanged. The duration follows the audio, and only one audio file can be used as is.', 'Choose "Generate video".'], }, example: { assets: [], diff --git a/web/updates.css b/web/updates.css index ce4d33cb..fbdac878 100644 --- a/web/updates.css +++ b/web/updates.css @@ -8,9 +8,18 @@ .fv-update-notice button:disabled{opacity:.5;cursor:default} .fv-update-working>span::before{content:'';display:inline-block;width:10px;height:10px;margin-right:8px;border:2px solid var(--fv-accent);border-right-color:transparent;border-radius:50%;vertical-align:-1px;animation:fv-update-spin .9s linear infinite} @keyframes fv-update-spin{to{transform:rotate(360deg)}} -.fv-update-notice :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px}.fv-view-navigation>.fv-update-notice{margin-top:8px;max-width:560px} +.fv-update-notice :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px} +/* The update notice is a full-width bar under the header, the same place in every view. */ +.fv-update-bar{flex:none;min-height:54px;box-sizing:border-box;align-items:center;margin:0;max-width:none;border:0;border-bottom:1px solid var(--fv-border);border-radius:0;box-shadow:none;padding:10px 28px;gap:8px 16px;flex-wrap:nowrap;font:14px/1.5 var(--fv-font);background:color-mix(in srgb,var(--fv-accent) 13%,var(--fv-bg))} +.fv-update-bar>span{min-width:0;color:var(--fv-ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fv-update-bar a,.fv-update-bar button,.fv-studio .fv-update-bar a,.fv-studio .fv-update-bar button{min-height:34px;padding:6px 14px;font-size:14px} +.fv-update-bar button:not(.fv-update-apply),.fv-studio .fv-update-bar button:not(.fv-update-apply){color:var(--fv-ink);opacity:.75} +.fv-update-bar .fv-update-apply,.fv-studio .fv-update-bar .fv-update-apply,.fv-update-bar a,.fv-studio .fv-update-bar a{background:transparent;color:var(--fv-accent);box-shadow:inset 0 0 0 1px var(--fv-accent)} +.fv-update-bar .fv-update-apply:hover,.fv-studio .fv-update-bar .fv-update-apply:hover,.fv-update-bar a:hover,.fv-studio .fv-update-bar a:hover{background:var(--fv-hover)} +.fv-view-navigation .fv-update-bar a{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;height:34px;min-height:34px;padding:0 14px;line-height:1} +.fv-view-navigation .fv-update-bar{margin:8px -20px -8px;border-bottom:0;border-top:1px solid var(--fv-border);border-radius:0 0 var(--fv-r-lg) var(--fv-r-lg);cursor:default} +@media(max-width:680px){.fv-update-bar{padding:10px 14px;flex-wrap:wrap;justify-content:flex-end}.fv-update-bar>span{flex:1 0 100%;white-space:normal}} @media(prefers-reduced-motion:reduce){.fv-update-notice{animation:none}.fv-update-working>span::before{animation:none}} -.fv-update-summary{flex-basis:100%;font-weight:400;color:var(--fv-muted);overflow-wrap:anywhere}.fv-update-summary[hidden]{display:none} .fv-version-info{font:inherit;font-size:12px;color:var(--fv-muted);background:transparent;border:0;border-radius:6px;padding:6px 10px;cursor:pointer;white-space:nowrap} .fv-version-info:hover{background:var(--fv-hover);color:var(--fv-ink)} /* Inside the studio the general button rule is more specific: keep the version a quiet label like Settings. */ diff --git a/web/updates.js b/web/updates.js index 3d8f0d80..4c6c7974 100644 --- a/web/updates.js +++ b/web/updates.js @@ -114,11 +114,10 @@ export function startUpdateChecks() { export function createUpdateNotice(cn) { const t = (en, zh) => cn ? zh : en; - const element = document.createElement('aside'); element.className = 'fv-update-notice'; + const element = document.createElement('aside'); element.className = 'fv-update-notice fv-update-bar'; element.hidden = true; element.setAttribute('role', 'status'); const label = document.createElement('span'); let problem; - const summary = document.createElement('small'); summary.className = 'fv-update-summary'; const details = document.createElement('button'); details.type = 'button'; details.textContent = t("What's new", '更新内容'); let closeNotes = () => {}; @@ -142,7 +141,7 @@ export function createUpdateNotice(cn) { const stop = document.createElement('button'); stop.type = 'button'; stop.textContent = t('Cancel update', '取消更新'); stop.onclick = () => { stop.disabled = true; void cancelUpdate().finally(() => { stop.disabled = false; }); }; - element.append(label, details, apply, link, dismiss, stop, summary); + element.append(label, details, apply, link, dismiss, stop); const render = () => { const launcher = value?.launcher, phase = launcher?.phase || '', candidate = value?.available; const mac = value?.channel === 'macos-preview', linux = value?.channel === 'linux-preview'; @@ -186,10 +185,6 @@ export function createUpdateNotice(cn) { problem ||= issueButton(t, () => ({where: ['Update FreeVideo', '更新 FreeVideo'], message: label.textContent})); element.append(problem); } else problem?.remove(); - const skipped = actions ? earlierNotes(candidate, productVersion()).length : 0; - summary.textContent = actions ? (localizedNotes(candidate, cn)?.summary || '') - + (skipped ? ' ' + t(`Includes the changes of ${skipped} earlier version${skipped > 1 ? 's' : ''}.`, `另含此前 ${skipped} 个版本的更新。`) : '') : ''; - summary.hidden = !summary.textContent; details.hidden = !actions; const direct = !!launcher && !launcher.manual && !failure; apply.hidden = !actions || !direct; diff --git a/web/view_navigation.js b/web/view_navigation.js index d580849e..08e1a677 100644 --- a/web/view_navigation.js +++ b/web/view_navigation.js @@ -176,8 +176,8 @@ export function installNavigation(openStudio, agent = false, {openWorkflow = nul if (created) handle?.notify(t('Created an editing project for you.', '已新建一个剪辑工程')); return handle; }, true), () => choose(node => openAutopilot(node), true), () => choose(node => openImage(node), true)), createLanAccess(t, api).element, createVersionInfo(cn).element, guideButton(t, 'fv-view-settings'), feedbackButton(t, 'fv-view-settings'), settings); toolbar.append(row); - toolbar.append(createUpdateNotice(cn).element); - document.body.append(toolbar); refreshNavigation(); + // In the node view the notice is the toolbar's second row, as wide as the toolbar. + toolbar.append(createUpdateNotice(cn).element); document.body.append(toolbar); refreshNavigation(); makeMovable(toolbar); // health.js asks for this before it reports the toolbar missing. A // workspace removed without closing must not keep the toolbar hidden. From 523e4118831c51b4e8dc80e7b882edf7f5d92d35 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 07:00:49 +0000 Subject: [PATCH 02/10] Image view: load pictures from the input folder where the engine stores them The engine saves generated pictures under input/freevideo-image, but the picture page requested them with type=output, so the big picture, the work thumbnails, the save link and the saved copy all returned 404. The page now requests type=input, and the page test fails if a displayed picture is not served from the input folder. --- web/image_view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/image_view.js b/web/image_view.js index c3b49205..d16ba227 100644 --- a/web/image_view.js +++ b/web/image_view.js @@ -73,7 +73,7 @@ export function createFakeImageClient({delay = 600, ready = {'minimax-h3-image': }; } -const pictureURL = file => { const at = file.lastIndexOf('/'); return api.apiURL('/view?' + new URLSearchParams({filename: file.slice(at + 1), subfolder: at < 0 ? '' : file.slice(0, at), type: 'output'})); }; +const pictureURL = file => { const at = file.lastIndexOf('/'); return api.apiURL('/view?' + new URLSearchParams({filename: file.slice(at + 1), subfolder: at < 0 ? '' : file.slice(0, at), type: 'input'})); }; const SAMPLE_PROMPTS = { "jazz": "A stylish concert poster in a mid-century modern graphic style: a saxophone player drawn as a bold silhouette in deep navy against a warm mustard-yellow background, with abstract curved shapes in teal and coral. At the top, a large clean title reads \"MIDNIGHT JAZZ\". At the bottom, one smaller line reads \"FRIDAY 9 PM\". All text is spelled exactly as given, with no other words on the poster. Flat print texture, strong composition.", "window": "A realistic portrait photograph of a woman in her late twenties sitting by a tall window in a quiet old apartment, soft overcast daylight on one side of her face, loose dark hair tucked behind one ear, wearing a rust-coloured linen shirt, holding a white ceramic cup with both hands, calm thoughtful expression looking slightly away from the camera, natural skin texture with fine detail, muted warm colours, shallow depth of field, 85mm lens, one person, no text.", From 19e739e858cb4fa0c9cbf9db34255b8ad0d3c320 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 07:50:08 +0000 Subject: [PATCH 03/10] Media panel: show how much of the file has been sent while uploading or saving an image --- web/freevideo.js | 19 +++++++++++++------ web/media_upload.js | 23 +++++++++++++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/web/freevideo.js b/web/freevideo.js index 61d632d5..7fe84648 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -205,9 +205,11 @@ function mediaPanel(node, mount = null) { return null; }; // One file into ComfyUI's input folder, through FreeVideo's streaming upload. - async function uploadOne(file) { - return uploadMedia(api, file); + async function uploadOne(file, onProgress = null) { + return uploadMedia(api, file, {onProgress}); } + // The share of the file sent so far, once at least one percent has gone. + const percent = fraction => fraction >= 0.01 ? ` · ${Math.floor(fraction * 100)}%` : ''; // An agent's edit leaves its editor document beside the picture: open that, so people continue its layers. async function agentEdit(file) { if (!file.startsWith('freevideo/agent/') || mediaKind(file) !== 'image') return null; @@ -244,7 +246,8 @@ function mediaPanel(node, mount = null) { node.isUploading = true; add.disabled = true; addAudio.disabled = true; message(text('Uploading the edited image…', '正在上传编辑后的图片…')); try { - const result = await uploadOne(new File([outcome.blob], outcome.name, {type: outcome.blob.type})); + const result = await uploadOne(new File([outcome.blob], outcome.name, {type: outcome.blob.type}), + fraction => message(text('Uploading the edited image', '正在上传编辑后的图片') + percent(fraction))); const latest = read(), index = latest.findIndex(row => row.file === file); if (index < 0) return; const hasOriginal = outcome.edit.layers.some(layer => layer.id === 'base'); @@ -267,8 +270,11 @@ function mediaPanel(node, mount = null) { if (!outcome?.blob) return; if (read().length >= 32) { message(text('The media list is full (32 items, including disabled ones). Remove some to add more.', '素材已满 32 个(含未启用的),请先移除不用的素材。'), true); return; } node.isUploading = true; add.disabled = addAudio.disabled = generateImage.disabled = true; + message(text('Saving the image…', '正在保存图片…')); try { - const result = await uploadOne(new File([outcome.blob], outcome.name, {type: outcome.blob.type})); + const result = await uploadOne(new File([outcome.blob], outcome.name, {type: outcome.blob.type}), + fraction => message(text('Saving the image', '正在保存图片') + percent(fraction))); + message(''); const latest = read(); latest.push({file: result.file, role: role.value, enabled: true, edit: outcome.edit}); update(latest); @@ -526,8 +532,9 @@ function mediaPanel(node, mount = null) { for (const [index, file] of usable.entries()) { if (read().length >= 32) throw new Error(text("Use at most 32 media items", "最多使用 32 个素材")); const kind = mediaKind(file.name); - message(`${text("Uploading", "正在上传")} ${index + 1}/${usable.length} · ${file.name}`); - const result = await uploadOne(file); + const heading = `${text("Uploading", "正在上传")} ${index + 1}/${usable.length} · ${file.name}`; + message(heading); + const result = await uploadOne(file, fraction => message(heading + percent(fraction))); // Read again after the wait: another place may have changed the list meanwhile. const rows = read(); if (rows.length >= 32) throw new Error(text("Use at most 32 media items", "最多使用 32 个素材")); rows.push({file: result.file, role: kind === "image" ? role.value : "reference", enabled: true}); update(rows); diff --git a/web/media_upload.js b/web/media_upload.js index c3c11927..d55213cd 100644 --- a/web/media_upload.js +++ b/web/media_upload.js @@ -16,7 +16,24 @@ const failures = () => ({ const generic = () => text('The upload did not finish. Try again.', '上传没有完成,请再试一次。'); // A request that never answers is cut off, so the pickers unlock and the failure line shows: two minutes plus four seconds per MiB, so a slow link is not mistaken for a hang. export const uploadTimeoutMs = size => 120000 + Math.ceil(size / 1024 ** 2) * 4000; -export async function uploadMedia(api, file, {timeoutMs = uploadTimeoutMs(file.size)} = {}) { +// With a progress callback the body goes through XMLHttpRequest, whose upload events report the bytes sent; fetch has none. +function sendWithProgress(api, body, onProgress, timeoutMs) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', api.apiURL('/freevideo/media/upload'), true); + xhr.setRequestHeader('Comfy-User', api.user ?? ''); + xhr.timeout = timeoutMs; + xhr.upload.onprogress = event => { if (event.lengthComputable && event.total > 0) onProgress(Math.min(1, event.loaded / event.total)); }; + xhr.onload = () => { + let result = {}; + try { result = JSON.parse(xhr.responseText); } catch { /* an unreadable reply is handled by the status */ } + resolve({ok: xhr.status >= 200 && xhr.status < 300, json: async () => result}); + }; + xhr.onerror = xhr.ontimeout = xhr.onabort = () => reject(new TypeError('Failed to fetch')); + xhr.send(body); + }); +} +export async function uploadMedia(api, file, {onProgress = null, timeoutMs = uploadTimeoutMs(file.size)} = {}) { const found = failures(); if (file.size > MAX_UPLOAD_BYTES) throw new Error(found.too_large); if (file.size === 0) throw new Error(found.empty); @@ -24,7 +41,9 @@ export async function uploadMedia(api, file, {timeoutMs = uploadTimeoutMs(file.s let response, result = {}; const abort = new AbortController(), timer = setTimeout(() => abort.abort(), timeoutMs); try { - response = await api.fetchApi('/freevideo/media/upload', {method: 'POST', body, signal: abort.signal}); + response = onProgress && typeof api.apiURL === 'function' && typeof XMLHttpRequest !== 'undefined' + ? await sendWithProgress(api, body, onProgress, timeoutMs) + : await api.fetchApi('/freevideo/media/upload', {method: 'POST', body, signal: abort.signal}); result = await response.json(); } catch { if (!response || response.ok) throw new Error(generic()); } finally { clearTimeout(timer); } From 0ded826c5ca73963071f4c1b60fcd777d469d29a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 07:49:36 +0000 Subject: [PATCH 04/10] Studio: refresh the mention overlay when a tour sample fills the prompt --- web/studio.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/studio.js b/web/studio.js index 3f506aa9..89efcd94 100644 --- a/web/studio.js +++ b/web/studio.js @@ -728,7 +728,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const {applyTourExample} = await import('./tour_try.js'); const target = castAssets(); const result = await applyTourExample({node, api, cn, rows: () => mediaRows(node), canApply: canTryTour, - setPrompt: text => { set(node, 'text', text); prompt.value = text; placeholder(); }, + setPrompt: text => { set(node, 'text', text); prompt.value = text; placeholder(); castOverlay.refresh(); }, // Two-pass and preview settle into one pass mode, whichever of the two the example lists first. setSetting: (name, input) => { if (name === 'quality') { From b88ad0411ebc0e8d170aca57cf235b71ae948962 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 07:58:22 +0000 Subject: [PATCH 05/10] Windows build: one Analysis for both package layouts, reusable work directory PyInstaller stores the excludes list after appending __main__ to it, so the next request never matched and Analysis always ran again. The build now names __main__ itself, adds its per-build data files after Analysis, and inserts the onefile runtime hook into the script list after Analysis at the position PyInstaller uses. Both layouts run the same Analysis, and a build with --workpath reuses it when the inputs are unchanged. --- scripts/build_windows.py | 55 ++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/scripts/build_windows.py b/scripts/build_windows.py index 27638d08..95aec11f 100644 --- a/scripts/build_windows.py +++ b/scripts/build_windows.py @@ -98,7 +98,25 @@ def version_file(out, identity): return path -def build_pyinstaller(command, out, *, onefolder): +def data_hook(datas): + """Add files PyInstaller would have taken from --add-data, after Analysis. + + Analysis reuses its cache only when every collected file is older than the + previous build. These files are written by every build, so they are merged + here with PyInstaller's own formatting and normalization instead: first in + the list, deduplicated together with binaries, as Analysis does. + """ + return f''' +from PyInstaller.building.datastruct import normalize_toc as _normalize_toc +from PyInstaller.building.utils import format_binaries_and_datas as _format_datas +_user = sorted(_normalize_toc([(d, s, 'DATA') for d, s in _format_datas({datas!r}, workingdir=SPECPATH)])) +_combined = _normalize_toc(_user + a.datas + a.binaries) +a.datas = [e for e in _combined if e[2] not in ('BINARY', 'EXTENSION')] +a.binaries = [e for e in _combined if e[2] in ('BINARY', 'EXTENSION')] +''' + + +def build_pyinstaller(command, out, *, onefolder, workpath=None, datas=()): # 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 @@ -112,14 +130,18 @@ def build_pyinstaller(command, out, *, onefolder): 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' from scripts.source_build import spec_hook injection = spec_hook(ROOT, out / 'engine-source', out / 'launcher-build.json') + # Data files join before pruning, as they did inside Analysis. + user = data_hook(list(datas)) if datas else '' if not onefolder: + # The onefile runtime hook goes in after Analysis, where PyInstaller puts a + # --runtime-hook (first script; its imports are all already collected), so both + # layouts run the same Analysis and the second build reuses the first's. pruning = f''' +a.scripts = [('pyi_rth_bundle_data', {str(ROOT / 'scripts/pyi_rth_bundle_data.py')!r}, 'PYSOURCE')] + a.scripts import sys sys.path.insert(0, {str(ROOT)!r}) from scripts.prune_qt_bundle import prune_qt_toc, write_prune_report @@ -133,12 +155,13 @@ def build_pyinstaller(command, out, *, onefolder): a.datas, deduplicated = deduplicate_toc(a.datas, {str(out)!r}) write_data_report({str(out)!r}, deduplicated) ''' + injection = user + injection 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)], + '--distpath', str(out / 'dist'), '--workpath', str(workpath or out / 'build'), str(spec)], cwd=ROOT, check=True) if onefolder: dist = out / 'dist/FreeVideo' @@ -150,6 +173,8 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--out', type=Path, required=True) parser.add_argument('--onefolder', action='store_true', help='Build a folder ZIP that runs without temporary self-extraction') + parser.add_argument('--workpath', type=Path, + help='PyInstaller work directory to reuse between builds of the same checkout path') args = parser.parse_args() if os.name != 'nt' or sys.version_info[:2] != (3, 12): parser.error('Build this artifact on Windows x64 with Python 3.12') @@ -177,22 +202,30 @@ def main(): stamp = stamp_source(staged, identity) notices = staged / 'freevideo_engine/launcher/licenses/bundled' collect_notices(ROOT, notices) + # Same files and order as --add-data, but merged after Analysis (see data_hook) + # so that a new build stamp does not invalidate a reused --workpath. + data = [str(staged) + os.pathsep + 'engine-source', + str(build_file) + os.pathsep + '.', + str(stamp) + os.pathsep + 'freevideo_engine', + str(stamp.parent / 'build-identity.json') + os.pathsep + 'freevideo_engine', + str(notices) + os.pathsep + 'freevideo_engine/launcher/licenses/bundled', + *bundle_data_args(ROOT)[1::2]] + datas = [tuple(item.split(os.pathsep, 1)) for item in data] command = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--windowed', '--noupx', '--onedir' if args.onefolder else '--onefile', '--name', 'FreeVideo', '--icon', str(ROOT / 'freevideo_engine/assets/icon.ico'), '--version-file', str(version_info), '--distpath', str(out / 'dist'), '--workpath', str(out / 'build'), '--specpath', str(out), - '--paths', str(ROOT), '--add-data', str(staged) + os.pathsep + 'engine-source', - '--add-data', str(build_file) + os.pathsep + '.', - '--add-data', str(stamp) + os.pathsep + 'freevideo_engine', - '--add-data', str(stamp.parent / 'build-identity.json') + os.pathsep + 'freevideo_engine', - '--add-data', str(notices) + os.pathsep + 'freevideo_engine/launcher/licenses/bundled', - *bundle_data_args(ROOT), + '--paths', str(ROOT), '--hidden-import', 'psutil', '--exclude-module', 'torch', '--exclude-module', 'triton', '--exclude-module', 'numpy', '--exclude-module', 'transformers', '--exclude-module', 'tkinter', + # PyInstaller appends __main__ to the stored list itself; naming it here keeps the + # stored and requested lists equal, so a reused --workpath can skip Analysis. + '--exclude-module', '__main__', str(ROOT / 'scripts/windows_app.py')] - build_pyinstaller(command, out, onefolder=args.onefolder) + workpath = args.workpath.resolve() if args.workpath else None + build_pyinstaller(command, out, onefolder=args.onefolder, workpath=workpath, datas=datas) 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') From f8ea5cfedbc7e5affa659280c39561a0b41198b3 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 07:28:28 +0000 Subject: [PATCH 06/10] Write the encoder folder map in an image-only install The shared text encoder reads it to find its weights; only a video model install wrote it, so a picture could not be generated after an image-only install. --- freevideo_engine/bootstrap.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index e4aa401d..36e71d89 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -1771,6 +1771,13 @@ def progress(value): self.ui.end(key, success=False) raise self.ui.end(key, detail='Verified and ready') + self.write_encoder_paths() + + def write_encoder_paths(self): + """The text-encoder folder map the shared encoder reads; a video model install writes it with its files.""" + from .monitoring import save + save(self.root / 'encoder-paths.yaml', + {'freevideo': {'base_path': str(self.plan['encoder_dir']), 'text_encoders': 'text_encoders/'}}) def missing_files(self, python, role, *, gpu=None, wheels=None): """Pinned packages of an environment whose files are gone (runtime_libraries.package_files). From aac3730b090b2917f40c25b9833f254e7ed63c8c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 16:35:11 +0000 Subject: [PATCH 07/10] Open an image-only installation: no video sampling cache required An image-only installation has no prepared video model, so its machine configuration carries no cache. The installation check no longer demands one, the launcher counts such an installation as ready without sampling levels, and the background video precompile is not started for it. --- freevideo_engine/comfy_bridge.py | 3 +++ freevideo_engine/comfy_launcher_runtime.py | 3 ++- freevideo_engine/comfy_nodes.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index 6f3dbcd9..d87b2541 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -892,6 +892,9 @@ def installation(source=None, environ=None): 'A prepared engine installation is required.') from error required = ('root', 'python', 'cache', 'base', 'checkpoint', 'comfy_python', 'comfy_root', 'vdn_root', 'model_paths', 'model_root', 'encoder') + if isinstance(machine, dict) and machine.get('video_model') is False: + # An image-only installation has no prepared video model, so no sampling cache. + required = tuple(k for k in required if k != 'cache') if not isinstance(machine, dict) or not machine.get('ready') or any(not machine.get(k) for k in required): raise ValueError('FreeVideo setup is incomplete in %s. %s Existing files are reused.' % (root, repair_hint())) identity = 'device_identity' if machine.get('device_backend') == 'mps' else 'gpu_uuid' diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index 52299bc5..c4a13783 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -1164,7 +1164,8 @@ def _inspect(self, values): _, machine = installation(source, {'FREEVIDEO_HOME': str(engine)}) # With every quality level requested, set up again only while some are missing. from .sampling_assets import installed as sampling_installed - ready = not values.get('sampling_caches') or sampling_installed(machine) + ready = (not values.get('sampling_caches') or machine.get('video_model') is False + or sampling_installed(machine)) except (OSError, ValueError, KeyError): pass self.selection = dict(descriptor, engine=str(engine), source=str(source), url=url, ready=ready, diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index 56d22fbf..0999c28b 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -633,7 +633,7 @@ def start_background_compile(): if os.environ.get('FREEVIDEO_BACKGROUND_COMPILE', 'auto').lower() in ('0', 'off', 'false'): return False root, machine = comfy_bridge.installation() - if machine.get('device_backend') == 'mps' or machine.get('gpu_vendor') == 'amd': + if machine.get('device_backend') == 'mps' or machine.get('gpu_vendor') == 'amd' or machine.get('video_model') is False: return False source = comfy_bridge.source_root() environment = comfy_bridge.engine_environment(root, source) From 95625db3a2ad9f6fb9ead271aaf8bda98a697d48 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 17:58:44 +0000 Subject: [PATCH 08/10] Image view: works grid keeps each row at its thumbnail height so many works scroll instead of... Image view: works grid keeps each row at its thumbnail height so many works scroll instead of stacking --- web/image_view.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/image_view.css b/web/image_view.css index 5ce716a2..e4088ddb 100644 --- a/web/image_view.css +++ b/web/image_view.css @@ -39,7 +39,7 @@ .fv-image-more .fv-image-action{border:0;background:transparent;justify-content:flex-start} .fv-image-info{margin:0;color:var(--fv-muted);font-size:var(--fv-micro)} .fv-image-works-column{min-height:0;display:flex;flex-direction:column;gap:8px;overflow:hidden}.fv-image-works-column[hidden]{display:none} -.fv-image-works{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:1fr 1fr;gap:8px;align-content:start} +.fv-image-works{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:1fr 1fr;gap:8px;align-content:start;grid-auto-rows:max-content} .fv-studio .fv-image-work{aspect-ratio:1;min-height:0;padding:0;overflow:hidden;border-radius:var(--fv-r-sm);position:relative} .fv-image-work[aria-pressed=true]{outline:2px solid var(--fv-accent);outline-offset:-2px} .fv-image-work-running{background:var(--fv-raised);border:1px solid var(--fv-border);aspect-ratio:1} From a2202d0816e8889b64cb0f479397fa518c8e195c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 18:14:07 +0000 Subject: [PATCH 09/10] Image view: the main preview shows square, portrait and landscape pictures whole at every width --- web/image_view.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/image_view.css b/web/image_view.css index e4088ddb..b28089d0 100644 --- a/web/image_view.css +++ b/web/image_view.css @@ -24,7 +24,7 @@ .fv-studio .fv-image-go-quiet{background:var(--fv-card);color:var(--fv-ink);border:1px solid var(--fv-border);border-radius:var(--fv-r-md);cursor:pointer} .fv-image-stage{min-width:0;min-height:0;display:flex;flex-direction:column;gap:12px} .fv-image-canvas{flex:1;min-height:0;position:relative;display:grid;place-items:center;overflow:hidden;border:1px solid transparent;border-radius:var(--fv-r-md);background:var(--fv-canvas,var(--fv-card))} -.fv-image-canvas>img{max-width:100%;max-height:100%;object-fit:contain} +.fv-image-canvas>img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain} .fv-image-card{display:grid;gap:8px;justify-items:center;text-align:center;padding:24px;color:var(--fv-muted);max-width:360px}.fv-image-card b{color:var(--fv-ink);font-size:var(--fv-strong)}.fv-image-card p{margin:0} .fv-image-download{display:block;width:240px;height:4px;border-radius:2px;background:var(--fv-border);overflow:hidden;position:relative} .fv-image-download::after{content:"";position:absolute;inset:0 auto 0 0;width:calc(var(--p,0) * 1%);background:var(--fv-accent)} From 7546a83d3c83d441f4f9c54533599ea0faf22eac Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Wed, 11 Nov 2026 19:33:21 +0000 Subject: [PATCH 10/10] Release 0.5.1: update notes --- freevideo_engine/release_notes.json | 122 ++++++++++++++-------------- 1 file changed, 62 insertions(+), 60 deletions(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 71e8efc0..92a7e620 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,76 @@ { "schema": 1, - "product_version": "0.5.0", + "product_version": "0.5.1", "en": { - "summary": "H3 Image arrives: a new Images view for H3 Image and Qwen-Image 2.1, pictures up to 16 MP on graphics cards from 8 GB, and Autopilot pictures follow the installed model. Beat lines join the editor, and launcher failures, settings and cards are clearer.", + "summary": "Installing only an image model works again, and the image view shows its pictures again. Uploads show progress, and the update notice, retry notice and reference Add menu are clearer.", "changes": [ - "H3 Image: a new image model for NVIDIA graphics cards from 8 GB of video memory, making pictures from 4 MP up to 16 MP. It is installed alone or added to an existing video install, which downloads only the missing files, about 22 GB, and shares the text encoder already installed. One weight file serves every graphics card, and machines with little memory load it in mapped form. Not available on Mac or AMD.", - "Images view: H3 Image and Qwen-Image 2.1 share one prompt box, and finished pictures gather in the Works column. Generated pictures are saved to the asset library and appear under Images in the reference \"+\" menu. The More menu offers Open folder, Copy prompt and Delete. The empty stage shows sample pictures, and the page has a wider layout, a solid Generate button and a clearer error card. H3 Image does not take reference pictures yet. With the prompt rewrite model installed, the \"FreeToken enhancement\" switch rewrites H3 Image prompts without adding anything the request did not ask for.", - "Autopilot: picture requests use the installed H3 Image at 4 MP or 16 MP; a request below 4 MP is made at 4 MP in the same shape.", - "Editor: beat lines on the music track, and clips snap to the beats. The feature tour now starts on the clip just imported.", - "Keyframes: still pictures can be placed between the first and last frame, and each is generated one at a time.", - "Audio: with \"Use as is\" selected and no audio connected, FreeVideo now says so before generating. When audio used as is cannot be generated in parts, the reason is shown and the request stops before it is submitted.", - "Windows: on machines with tight system memory, the second pass no longer stops and restarts for lack of memory. The video is unchanged.", - "Long videos: very long high-resolution requests no longer stop on the first step with a numerical error.", - "Sampling preview: an optional small preview decoder on CUDA, used only when it does not slow generation.", - "Settings: the working files of finished videos can be cleared.", - "Launcher: a failure shows one card with the next step and the buttons that resolve it, and the technical details are folded away. A CUDA initialization failure comes with the step to take, an outdated driver gets a card asking to update the driver, and a failed update records why each source failed.", - "Interface details: checkboxes share one size; the Passes field is as compact as the Reserved VRAM field; media cards no longer show a mode label; editing a character puts the cursor in its name; Autopilot cards show \"Custom\" for a custom quality and a single progress percentage; and the header and view tabs fit narrow windows without overlap or sideways scrolling." + "Install: installing only an image model no longer fails at the verification step for lacking video model folders; the check for a video install is unchanged.", + "Image view: the large picture, the work thumbnails and the save link no longer return 404.", + "Uploads: while a file is uploaded or an edited image is saved, the media panel shows how much has been sent.", + "Update notice: it is a full-width bar, under the header in the studio and as the second row of the node toolbar.", + "Retry notice: when a run is retried, the notice says the run mode changes, and names less video memory or less memory by cause, instead of saying settings change.", + "Reference Add menu: it opens above when there is no room below, takes the full width on narrow windows and shows four columns at 400 px wide.", + "Studio: the mention list refreshes when a tour sample fills the prompt.", + "Tutorial and tour: video and audio references are added with Add, the same way as images, and the first-and-last-frame step no longer repeats Add.", + "Agent: the width and height descriptions state the nearest-multiple-of-32 rule, and whole-timeline write points to the editor operations for clip edits.", + "Image view: when there are many works, the thumbnails in the works column are shown in full instead of being squeezed into thin strips.", + "Image view: the large picture shows square, portrait and landscape pictures whole at every width." ] }, "zh": { - "summary": "H3 Image 上线:图像视图同时支持两个图像模型,8 GB 显存起即可出最高 16 MP 的图片,AI 全自动出图按已安装的模型生成;剪辑新增拍点线,启动器失败提示、设置和卡片更清楚。", + "summary": "只装图像模型的安装恢复正常,图像视图的图片重新显示;上传显示进度,更新提示、重试提示和参考图“添加”菜单更清楚。", "changes": [ - "H3 Image:新增图像模型,适用于 8 GB 显存起的 NVIDIA 显卡,可出 4 MP 到 16 MP 的图片。可单独安装,也可加装到已有的视频安装上,加装只下载缺少的文件,约 22 GB,并共用已安装的文本编码器;所有显卡用同一份权重文件,内存较小的机器按映射方式加载。Mac 和 AMD 暂不可用。", - "图像视图:H3 Image 和 Qwen-Image 2.1 共用一个提示词输入框,生成的图片集中在“作品”栏。生成的图片会存入素材库,并出现在参考图“+”菜单的“图片”下;“更多”菜单提供打开文件夹、复制提示词和删除。空白舞台显示示例图,页面布局更宽,“生成”按钮始终实心,出错时显示更清楚的提示卡。H3 Image 暂不支持参考图。装有提示词改写模型时,可打开“FreeToken 提示词增强”改写 H3 Image 的提示词,不会加入请求里没有的内容。", - "AI 全自动:出图请求用已安装的 H3 Image 按 4 MP 或 16 MP 生成;不足 4 MP 的请求按同一比例出 4 MP。", - "剪辑:音乐轨新增拍点线,片段可吸附到拍点;功能导览改为从刚导入的片段开始。", - "关键帧:首帧和尾帧之间可放置静态图片,每次生成一张。", - "音频:选了“原样使用”但没有连接音频时,生成前就会提示;原样使用的音频无法分段生成时,会说明原因并在提交前停止。", - "Windows:系统内存紧张的机器上,第二遍不再因内存不足而中途停止并重试;生成的视频不变。", - "长视频:很长的高分辨率请求不再在第一步因数值错误停止。", - "采样预览:CUDA 上新增可选的小预览解码器,只在不拖慢生成时启用。", - "设置:可清理已完成视频的工作文件。", - "启动器:失败时只显示一张卡,写明下一步和能解决问题的按钮,技术详情折叠;CUDA 初始化失败会给出可操作的步骤,显卡驱动过旧时显示更新驱动的卡,更新失败时记录每个来源失败的原因。", - "界面细节:勾选框统一大小;“轮数”输入框与“预留显存”输入框一样紧凑;素材卡不再显示模式标签;编辑角色时光标先落在名字上;AI 全自动卡片在自定义质量时显示“自定义”,进度只显示一个百分比;顶栏和视图标签在窄窗口里不再重叠,也不再横向滚动。" + "安装:只安装图像模型时,安装不再在“验证安装”一步因缺少视频模型目录而失败;安装视频模型时的检查不变。", + "图像视图:大图、作品缩略图和保存链接不再 404。", + "上传:上传文件或保存编辑后的图片时,素材面板显示已发送的比例。", + "更新提示:改为通栏,创作面板里在标题下,节点视图里是工具条的第二行。", + "重试提示:运行自动重试时,提示写明换了一种运行方式,并按原因写“更省显存”或“更省内存”,不再写“调整设置”。", + "参考图“添加”菜单:下方没有位置时向上展开,窄窗口通宽,400 宽下为四列。", + "创作面板:示例图填入描述后,@ 提及列表同步刷新。", + "教程与导览:视频和音频参考与图片一样点“添加”,首尾帧一步不再重复“添加”。", + "Agent:宽高说明写明取最近的 32 的倍数;整条时间线写入指向剪辑操作,避免用它改片段。", + "图像视图:作品较多时,作品栏的缩略图完整显示,不再被压成一条条。", + "图像视图:大图完整显示方图、竖图和横图。" ] }, "history": [ + { + "product_version": "0.5.0", + "en": { + "summary": "H3 Image arrives: a new Images view for H3 Image and Qwen-Image 2.1, pictures up to 16 MP on graphics cards from 8 GB, and Autopilot pictures follow the installed model. Beat lines join the editor, and launcher failures, settings and cards are clearer.", + "changes": [ + "H3 Image: a new image model for NVIDIA graphics cards from 8 GB of video memory, making pictures from 4 MP up to 16 MP. It is installed alone or added to an existing video install, which downloads only the missing files, about 22 GB, and shares the text encoder already installed. One weight file serves every graphics card, and machines with little memory load it in mapped form. Not available on Mac or AMD.", + "Images view: H3 Image and Qwen-Image 2.1 share one prompt box, and finished pictures gather in the Works column. Generated pictures are saved to the asset library and appear under Images in the reference \"+\" menu. The More menu offers Open folder, Copy prompt and Delete. The empty stage shows sample pictures, and the page has a wider layout, a solid Generate button and a clearer error card. H3 Image does not take reference pictures yet. With the prompt rewrite model installed, the \"FreeToken enhancement\" switch rewrites H3 Image prompts without adding anything the request did not ask for.", + "Autopilot: picture requests use the installed H3 Image at 4 MP or 16 MP; a request below 4 MP is made at 4 MP in the same shape.", + "Editor: beat lines on the music track, and clips snap to the beats. The feature tour now starts on the clip just imported.", + "Keyframes: still pictures can be placed between the first and last frame, and each is generated one at a time.", + "Audio: with \"Use as is\" selected and no audio connected, FreeVideo now says so before generating. When audio used as is cannot be generated in parts, the reason is shown and the request stops before it is submitted.", + "Windows: on machines with tight system memory, the second pass no longer stops and restarts for lack of memory. The video is unchanged.", + "Long videos: very long high-resolution requests no longer stop on the first step with a numerical error.", + "Sampling preview: an optional small preview decoder on CUDA, used only when it does not slow generation.", + "Settings: the working files of finished videos can be cleared.", + "Launcher: a failure shows one card with the next step and the buttons that resolve it, and the technical details are folded away. A CUDA initialization failure comes with the step to take, an outdated driver gets a card asking to update the driver, and a failed update records why each source failed.", + "Interface details: checkboxes share one size; the Passes field is as compact as the Reserved VRAM field; media cards no longer show a mode label; editing a character puts the cursor in its name; Autopilot cards show \"Custom\" for a custom quality and a single progress percentage; and the header and view tabs fit narrow windows without overlap or sideways scrolling." + ] + }, + "zh": { + "summary": "H3 Image 上线:图像视图同时支持两个图像模型,8 GB 显存起即可出最高 16 MP 的图片,AI 全自动出图按已安装的模型生成;剪辑新增拍点线,启动器失败提示、设置和卡片更清楚。", + "changes": [ + "H3 Image:新增图像模型,适用于 8 GB 显存起的 NVIDIA 显卡,可出 4 MP 到 16 MP 的图片。可单独安装,也可加装到已有的视频安装上,加装只下载缺少的文件,约 22 GB,并共用已安装的文本编码器;所有显卡用同一份权重文件,内存较小的机器按映射方式加载。Mac 和 AMD 暂不可用。", + "图像视图:H3 Image 和 Qwen-Image 2.1 共用一个提示词输入框,生成的图片集中在“作品”栏。生成的图片会存入素材库,并出现在参考图“+”菜单的“图片”下;“更多”菜单提供打开文件夹、复制提示词和删除。空白舞台显示示例图,页面布局更宽,“生成”按钮始终实心,出错时显示更清楚的提示卡。H3 Image 暂不支持参考图。装有提示词改写模型时,可打开“FreeToken 提示词增强”改写 H3 Image 的提示词,不会加入请求里没有的内容。", + "AI 全自动:出图请求用已安装的 H3 Image 按 4 MP 或 16 MP 生成;不足 4 MP 的请求按同一比例出 4 MP。", + "剪辑:音乐轨新增拍点线,片段可吸附到拍点;功能导览改为从刚导入的片段开始。", + "关键帧:首帧和尾帧之间可放置静态图片,每次生成一张。", + "音频:选了“原样使用”但没有连接音频时,生成前就会提示;原样使用的音频无法分段生成时,会说明原因并在提交前停止。", + "Windows:系统内存紧张的机器上,第二遍不再因内存不足而中途停止并重试;生成的视频不变。", + "长视频:很长的高分辨率请求不再在第一步因数值错误停止。", + "采样预览:CUDA 上新增可选的小预览解码器,只在不拖慢生成时启用。", + "设置:可清理已完成视频的工作文件。", + "启动器:失败时只显示一张卡,写明下一步和能解决问题的按钮,技术详情折叠;CUDA 初始化失败会给出可操作的步骤,显卡驱动过旧时显示更新驱动的卡,更新失败时记录每个来源失败的原因。", + "界面细节:勾选框统一大小;“轮数”输入框与“预留显存”输入框一样紧凑;素材卡不再显示模式标签;编辑角色时光标先落在名字上;AI 全自动卡片在自定义质量时显示“自定义”,进度只显示一个百分比;顶栏和视图标签在窄窗口里不再重叠,也不再横向滚动。" + ] + } + }, { "product_version": "0.4.38", "en": { @@ -287,39 +322,6 @@ "音频卡:“先出预览”开启或接着生成时,“原样使用”不再变灰。选择它会关闭预览并用一行说明;接着生成时说明原因和需要移除的内容。" ] } - }, - { - "product_version": "0.4.30", - "en": { - "summary": "Image generation on Windows machines with little memory no longer stops while the model loads, large requests are steadier in the second pass, and the editor, the launcher and phone access now explain what stopped and what to do next.", - "changes": [ - "Image generation: on Windows machines with little free memory, loading the image model no longer crashes. If loading is interrupted anyway, FreeVideo retries the request once automatically.", - "Generation: large requests on Windows are less likely to run out of memory in the second pass, and when they do, the retry is aimed at the memory that actually ran short instead of falling back to much slower settings. A generation also no longer stops when a memory copy is refused during sampling.", - "Creation panel: a note appears when other programs are using enough GPU memory or RAM to slow a generation down, and a generation that fails because of that shortage says so.", - "Prompt enhancement: a prompt over the 12,000-character limit now shows its current length, counting the descriptions of characters mentioned with @, before anything is sent. When the prompt or references change while a rewrite is running, a plain note says so instead of a red error. Time points later than the end of the video no longer cause an error; they are fitted within the video's length.", - "Editor: playback stops with a notice at a clip that cannot be played, instead of running on over a black picture. After FreeVideo restarts, interrupted exports, multi-part generations and subtitle jobs no longer show as in progress, and can be started again or continued.", - "Phone access: when phone access cannot be opened, the page says why and offers \"Try again\", instead of showing \"Opening...\" indefinitely.", - "Start-up: the launcher's failure cards now explain the cause and the next step. An access-denied error is titled \"Access denied\" and shows where access was denied; a port in use offers \"Change address\"; a refused setup step, an expired diagnostic report and a failed report export also show the next step. When Python fails to install during setup, the card shows the installer's own error message instead of only an exit code. Exporting a report can replace an existing file.", - "Install: while a downloaded image model file is being verified, its card says \"Checking\". On Windows, entry folders left in the shared FV folder by removed installations are cleaned up. If the setup tool has gone missing before an install step, it is downloaded again instead of the step failing.", - "Multi-GPU machines: requests from the Creation panel and the Agent that include images, continue a clip or are longer now run on a free card alongside other generations. A single very large task runs on its own instead of waiting in the queue indefinitely.", - "GPU memory: very long second passes, such as ones with large reference videos, need less GPU memory on smaller cards. When other programs take GPU memory during a run, decoding adapts instead of failing and starting over. Results are unchanged." - ] - }, - "zh": { - "summary": "内存较小的 Windows 机器生成图片时,不再在加载模型时中断;大画面请求的第二遍更稳定;剪辑视图、启动器和手机访问出问题时,会说明原因和下一步。", - "changes": [ - "图像生成:可用内存较小的 Windows 机器加载图像模型时不再崩溃。加载仍意外中断时,FreeVideo 会自动重试一次。", - "生成:Windows 上的大画面请求在第二遍更不容易出现内存不足;出现时,重试会针对实际不足的那部分内存,不再退到慢得多的设置。采样中途内存拷贝被拒绝时,生成也不再中止。", - "创作面板:其他程序占用的显存或内存会使生成变慢时,面板会提示;因此类不足导致生成失败时,失败卡片同样说明这一点。", - "提示词增强:提示词超过 12,000 字符上限时,发送前会显示当前字数,展开的 @角色描述一并计入。改写过程中原文或素材有改动时,改为普通提示,不再显示红色错误。提示词里晚于视频结尾的时间点不再报错,会调整到视频时长以内。", - "剪辑视图:播放到无法播放的素材时会停下并提示,不再黑屏空走。FreeVideo 重启后,中断的导出、分段生成和字幕任务不再显示为进行中,可以重新开始或继续。", - "手机访问:手机连接无法打开时,网页会说明原因并提供“重试”,不再一直显示“正在打开…”。", - "启动:启动器的失败卡片会说明原因和下一步。拒绝访问类错误的标题为“访问被拒绝”,并写出被拒绝的位置;端口被占用时提供“更改地址”;安装步骤被拒绝、诊断报告过期和导出报告失败时,也会给出下一步。安装过程中 Python 安装失败时,失败卡片会显示安装器的报错,而不只是退出码。导出报告时可以覆盖已有文件。", - "安装:下载完成的图像模型文件在校验时,卡片显示“正在校验”。Windows 上,已删除的安装在共用 FV 目录里留下的入口目录会被清理。安装步骤开始前如果安装工具缺失,会重新下载,安装步骤不再因此失败。", - "多显卡机器:创作面板和 Agent 发出的带图、续接或较长的请求,现在可以在空闲的显卡上与其他生成同时运行。单个很大的任务改为单独运行,不再一直排队。", - "显存:较长的第二遍(例如参考视频很大的请求)在小显存显卡上占用的显存更少。运行中其他程序占用显存时,解码会自行调整,不再失败后重新开始。结果不变。" - ] - } } ] }