From c992a188ef3261a4ae98d91a1d12c8e26119429b Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:28:11 +0000 Subject: [PATCH 01/18] The command line generates correctly from a deep install root --- freevideo_engine/cli.py | 7 +++++++ freevideo_engine/launch_view.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/freevideo_engine/cli.py b/freevideo_engine/cli.py index 399a4506..2767d473 100644 --- a/freevideo_engine/cli.py +++ b/freevideo_engine/cli.py @@ -30,6 +30,13 @@ def main(): if len(sys.argv) > 1 and sys.argv[1] in ('projects', 'sessions'): from .agent.cli import main as agent return agent(sys.argv[1:]) + if not getattr(sys, 'frozen', False) and not {'-h', '--help'} & set(sys.argv[1:]): + from .launch_view import restart_arguments + restart = restart_arguments(sys.argv[1:]) + if restart is not None: + # Deep installation folder: Windows cannot load the libraries by their full path. + from . import processes + raise SystemExit(processes.run(restart[0], env=restart[1], start_new_session=False).returncode) if len(sys.argv) > 1 and sys.argv[1] == 'predict': from .prediction_cli import main as predict raise SystemExit(predict(sys.argv[2:])) diff --git a/freevideo_engine/launch_view.py b/freevideo_engine/launch_view.py index e97ef240..c6795831 100644 --- a/freevideo_engine/launch_view.py +++ b/freevideo_engine/launch_view.py @@ -11,6 +11,7 @@ import os from pathlib import Path import re +import sys from .long_paths import io_path, plain @@ -93,6 +94,30 @@ def for_engine(engine_root) -> View: return View(engine, engine) +def restart_arguments(arguments, prefix=None, environ=None): + """The command line and environment that run this command again from the engine's short folder. + + None where the process is not an engine's own Python on Windows, already runs from a + short folder, or its folder needs none. A command-line run reaches the libraries + (torch\\lib\\c10.dll) by the interpreter's own path, so only a restart can shorten it. + """ + from . import runtime_alias + environ = os.environ if environ is None else environ + if not runtime_alias._backend.windows() or current(environ) is not None: + return None + prefix = Path(plain(sys.prefix if prefix is None else prefix)) + if prefix.parent.name.casefold() != 'envs': + return None + try: + view = for_engine(prefix.parent.parent) + except OSError: + return None + if not view.used: + return None + command, child, _ = execution([sys.executable, '-m', 'freevideo_engine', *arguments], environ, None, view) + return command, child + + def for_file(path): """The view of the offline runtime that holds this file, or None. From f9b987cad4a2eb140c1d633ae18005b61bb3dd14 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:28:12 +0000 Subject: [PATCH 02/18] Image history entries can be deleted from the library --- freevideo_engine/image_gen/service.py | 33 ++++++++++++- web/image_editor.css | 10 ++++ web/image_generation.js | 70 ++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 1b4d5cd7..a7b9e681 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -288,16 +288,43 @@ def history(self): names = sorted(named, reverse=True) + [name for _, name in sorted(older, reverse=True)] return dict(items=[dict(file='freevideo-image/' + name, name=name) for name in names[:HISTORY_LIMIT]]) - def history_delete(self, name): + def history_path(self, name): + """The picture a history name stands for; ValueError when it is none of ours, missing when it is already gone.""" leaf = name.rpartition('/')[2] if isinstance(name, str) else '' if not (HISTORY_NAME.fullmatch(leaf) or LEGACY_NAME.fullmatch(leaf)) or name != 'freevideo-image/' + leaf: raise ValueError('invalid_media') path = self.output_directory() / leaf - if path.is_symlink() or not path.is_file() or (LEGACY_NAME.fullmatch(leaf) and not self.legacy_picture(path)): + if path.is_symlink() or (path.exists() and not path.is_file()) or ( + LEGACY_NAME.fullmatch(leaf) and path.is_file() and not self.legacy_picture(path)): + raise ValueError('invalid_media') + return path + + def history_delete(self, name): + path = self.history_path(name) + if not path.is_file(): raise ValueError('invalid_media') path.unlink() return self.history() + def history_delete_many(self, names): + """Delete each picture on its own; the ones that stay are listed with why (busy, missing, invalid).""" + if not isinstance(names, list) or len(names) > HISTORY_LIMIT: + raise ValueError('invalid_request') + failed = [] + for name in dict.fromkeys(names): + try: + path = self.history_path(name) + except ValueError: + failed.append(dict(file=name if isinstance(name, str) else '', reason='invalid')) + continue + try: + path.unlink() + except FileNotFoundError: + failed.append(dict(file=name, reason='missing')) + except OSError: + failed.append(dict(file=name, reason='busy')) + return dict(self.history(), failed=failed) + def history_open(self): """Show the folder in the file manager of this machine.""" folder = str(self.output_directory()) @@ -1199,6 +1226,8 @@ async def action(request): result = service.history() elif name == 'history-delete': result = service.history_delete(value.get('file')) + elif name == 'history-delete-many': + result = service.history_delete_many(value.get('files')) elif name == 'history-open': if request.remote not in ('127.0.0.1', '::1'): raise ValueError('invalid_request') diff --git a/web/image_editor.css b/web/image_editor.css index a9369f14..e9b0e64f 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -94,6 +94,16 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-gen-history-use{position:absolute;top:4px;left:4px;width:20px;height:20px;padding:0;border:0;border-radius:10px;background:rgba(0,0,0,.7);color:#fff;font-size:16px;line-height:18px;opacity:0;cursor:pointer} .fv-ie-gen-history-tile:hover>.fv-ie-gen-history-use,.fv-ie-gen-history-tile:hover>.fv-ie-gen-history-delete,.fv-ie-gen-history-use:focus-visible,.fv-ie-gen-history-delete:focus-visible{opacity:1} .fv-ie-gen-history-delete{opacity:0;top:4px;right:4px;width:20px;height:20px;border-radius:10px;font-size:16px;line-height:18px;background:rgba(0,0,0,.7);color:#fff} +.fv-ie-gen-history-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:4px;margin-top:6px} +.fv-ie-gen-history-foot-end{display:flex;gap:4px} +.fv-ie-gen-history-foot .fv-ie-button{min-height:32px;padding:5px 9px} +.fv-ie-gen-history-delete-all{min-width:88px} +.fv-ie-gen-history-foot .fv-ie-gen-history-confirm{background:var(--fv-danger);border-color:var(--fv-danger);color:#fff} +.fv-ie-gen-history-idle{opacity:.5;pointer-events:none} +.fv-ie-gen-history-ticked img{outline:2px solid var(--fv-accent,#3b82f6);outline-offset:-2px} +.fv-ie-gen-history-tick{position:absolute;top:4px;left:4px;width:20px;height:20px;border:2px solid #fff;border-radius:10px;background:rgba(0,0,0,.45);color:#fff;font-size:13px;line-height:16px;text-align:center;pointer-events:none} +.fv-ie-gen-history-on{background:var(--fv-accent,#3b82f6)} +.fv-ie-gen-history-why{position:absolute;left:0;right:0;bottom:0;padding:2px 4px;border-radius:0 0 6px 6px;background:rgba(192,57,43,.9);color:#fff;font-size:var(--fv-micro);text-align:center;pointer-events:none} .fv-image-editor .fv-ie-gen-prompt{position:absolute;inset:0 0 auto;width:100%;height:32px;min-height:32px;max-height:80px;resize:none;padding:3px 9px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-raised);line-height:24px;overflow-y:auto} .fv-ie-gen-prompt:disabled{opacity:.5} .fv-ie-gen-note{align-self:center;min-width:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums} diff --git a/web/image_generation.js b/web/image_generation.js index c2dd08bb..894a0eb3 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -106,6 +106,7 @@ export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThi // Every picture generated on this machine, newest first; the engine keeps them in a folder. async history() { return (await post('history', {})).items || []; }, async historyDelete(file) { return (await post('history-delete', {file})).items || []; }, + async historyDeleteMany(files) { const answer = await post('history-delete-many', {files}); return {items: answer.items || [], failed: answer.failed || []}; }, historyOpen: () => post('history-open', {}), // The prompts and sizes of a new character's five pictures (the Agent's cards use the same ones). castViews: async description => (await post('cast-views', {description})).views, @@ -649,42 +650,95 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection summary.title = t('History', '历史'); summary.setAttribute('aria-label', summary.title); const list = el('div', null, 'fv-ie-menu-list'); menu.append(summary, list); + // Choosing several: the ticked files, the reason each one that stayed gives, and the two-step confirm of the delete button. + let selecting = false, armed = null, failed = false; + const chosen = new Set(), stayed = new Map(); + const reasons = {busy: t('In use', '被占用'), missing: t('Already gone', '已不存在'), invalid: t('Not deleted', '未删除')}; + const disarm = () => { clearTimeout(armed); armed = null; }; const draw = () => { list.replaceChildren(); + if (failed) list.append(el('div', t('Could not delete this image', '无法删除这张图片'), 'fv-ie-menu-label')); if (!past.length) { list.append(el('div', t('No images generated yet', '还没有生成过图片'), 'fv-ie-menu-label')); return; } const grid = el('div', null, 'fv-ie-gen-history-grid'); for (const item of past) { const tile = el('div', null, 'fv-ie-gen-history-tile'); - const place = button('', item.name, () => { menu.open = false; onPlace?.(item.file, item.name); }, 'fv-ie-gen-history-pick'); + const ticked = chosen.has(item.file); + const place = button('', item.name, () => { + if (!selecting) { menu.open = false; onPlace?.(item.file, item.name); return; } + if (chosen.has(item.file)) chosen.delete(item.file); else chosen.add(item.file); + stayed.delete(item.file); disarm(); draw(); + }, 'fv-ie-gen-history-pick' + (ticked ? ' fv-ie-gen-history-ticked' : '')); + if (selecting) place.setAttribute('aria-pressed', String(ticked)); const picture = el('img'); picture.src = resolve(item.file); picture.alt = item.name; picture.loading = 'lazy'; place.append(picture); tile.append(place); + if (selecting) { + tile.append(el('span', ticked ? '✓' : '', 'fv-ie-gen-history-tick' + (ticked ? ' fv-ie-gen-history-on' : ''))); + if (stayed.has(item.file)) tile.append(el('span', stayed.get(item.file), 'fv-ie-gen-history-why')); + grid.append(tile); + continue; + } if (mode === 'edit' && getDoc().layers.length && limit() > 1) { const use = button('+', t('Use as reference', '用作参考图'), () => { references = addReference(references, {file: item.file, label: item.name}, limit()); menu.open = false; update(); }, 'fv-ie-gen-history-use'); use.setAttribute('aria-label', use.title); tile.append(use); } - let armed = null; + let single = null; const remove = button('×', t('Delete', '删除'), async () => { - if (!armed) { + if (!single) { remove.textContent = t('Delete', '删除'); remove.classList.add('fv-ie-gen-history-confirm'); - armed = setTimeout(() => { armed = null; remove.textContent = '×'; remove.classList.remove('fv-ie-gen-history-confirm'); }, 3000); + single = setTimeout(() => { single = null; remove.textContent = '×'; remove.classList.remove('fv-ie-gen-history-confirm'); }, 3000); return; } - clearTimeout(armed); - try { past = await client.historyDelete(item.file); } catch { return; } + clearTimeout(single); + try { past = await client.historyDelete(item.file); failed = false; } catch { + // Read the folder again: a picture that is gone just leaves the list, one that is still there is named as kept. + failed = true; + try { past = await client.history(); failed = past.some(row => row.file === item.file); } catch { /* keep the list shown */ } + } draw(); }, 'fv-ie-gen-ref-remove fv-ie-gen-history-delete'); remove.setAttribute('aria-label', remove.title); tile.append(remove); grid.append(tile); } list.append(grid); - if (['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) - list.append(button(t('Open folder', '打开文件夹'), null, () => { void client.historyOpen().catch(() => {}); }, 'fv-ie-menu-item')); + const foot = el('div', null, 'fv-ie-gen-history-foot'); + if (selecting) { + const all = button(chosen.size === past.length ? t('Deselect all', '全不选') : t('Select all', '全选'), null, () => { + if (chosen.size === past.length) chosen.clear(); else for (const row of past) chosen.add(row.file); + stayed.clear(); disarm(); draw(); + }, 'fv-ie-button'); + const del = button(armed ? t('Confirm delete', '确认删除') : t('Delete ' + chosen.size, '删除 ' + chosen.size + ' 张'), null, async () => { + if (!chosen.size) return; + if (!armed) { armed = setTimeout(() => { armed = null; draw(); }, 3000); draw(); return; } + disarm(); + let answer; + try { answer = await client.historyDeleteMany([...chosen]); } catch { answer = null; } + stayed.clear(); failed = false; + if (!answer) { + try { past = await client.history(); } catch { /* keep the list shown */ } + for (const file of chosen) if (past.some(row => row.file === file)) stayed.set(file, reasons.invalid); + } else { + past = answer.items; + for (const row of answer.failed) if (row.reason !== 'missing') stayed.set(row.file, reasons[row.reason] || reasons.invalid); + } + for (const file of [...chosen]) if (!stayed.has(file)) chosen.delete(file); + draw(); + }, 'fv-ie-button fv-ie-danger fv-ie-gen-history-delete-all' + (armed ? ' fv-ie-gen-history-confirm' : '') + (chosen.size ? '' : ' fv-ie-gen-history-idle')); + const cancel = button(t('Cancel', '取消'), null, () => { selecting = false; chosen.clear(); stayed.clear(); disarm(); draw(); }, 'fv-ie-button'); + foot.append(all, el('span', null, 'fv-ie-gen-history-foot-end')); + foot.lastChild.append(cancel, del); + } else { + foot.append(button(t('Select', '选择'), null, () => { selecting = true; failed = false; draw(); }, 'fv-ie-menu-item')); + if (['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) + foot.append(button(t('Open folder', '打开文件夹'), null, () => { void client.historyOpen().catch(() => {}); }, 'fv-ie-menu-item')); + } + list.append(foot); }; menu.ontoggle = async () => { if (!menu.open) return; try { past = await client.history(); } catch { past = []; } + selecting = false; chosen.clear(); stayed.clear(); disarm(); failed = false; draw(); // Open upward when the list does not fit below, and keep it inside the window. list.classList.remove('fv-ie-gen-history-up'); list.style.right = ''; From 871575afc042bb97fc5d40b8405a713f980aab93 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:28:21 +0000 Subject: [PATCH 03/18] Windows int8: take eight attention heads on 12 GB cards beyond 72576 rows --- freevideo_engine/policy.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index f52ee983..9754a8f4 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -118,6 +118,22 @@ # head 8 with none (8.43 s, 4.18 GiB). WINDOWS_INT8_HEAD8_TOKENS = 72576 WINDOWS_INT8_HEAD8_EXTRA = int(1.15 * GiB) +# Beyond that row count the four-head plan stayed on 12 GB cards although the +# budget covers eight heads. Reserved peak of eight heads and no resident +# block, complete requests with the allocator capped at 10.48 GiB and the +# Windows allocator emulated (no expandable segments), 1344x768: +# +# 72576 rows 7.04 GiB 97776 rows 9.40 107856 rows 10.23 +# +# Four heads with two blocks at 97776 rows reserved 8.81 here and 9.31 under +# Windows on an RTX 3060, so 0.5 GiB is added for the Windows allocator and +# compositor. Eight heads charge the larger of the figure below and the plan at +# 72576 rows, which keeps the requirement continuous there and never lower than +# before. Frames do not matter, only rows: the figures come from 243, 328 and +# 360 frames. +WINDOWS_INT8_LONG_HEAD8_TOKENS = 107856 +WINDOWS_INT8_LONG_HEAD8_BASE = int(7.54 * GiB) +WINDOWS_INT8_LONG_HEAD8_PER_ROW = .0937 * GiB / 1000 WINDOWS_INT8_HEAD8_AMPERE_EXTRA = int(.3 * GiB) # Below that band an 8 GiB card stages the residual stream, and the staged # pass kept four heads. With the Windows allocator (no expandable segments) @@ -1178,7 +1194,18 @@ def held(group): and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES and effective_tokens <= WINDOWS_INT8_STAGED_HEAD8_TOKENS) + windows_int8_long = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] + and effective_tokens is not None + and not (canvas.get('reference_video_tokens') or canvas.get('reference_audio_tokens')) + and WINDOWS_INT8_HEAD8_TOKENS < effective_tokens <= WINDOWS_INT8_LONG_HEAD8_TOKENS) reserves = SMALL_ACTIVATION_RESERVES + if windows_int8_long: + four = dict(SMALL_ACTIVATION_RESERVES)[4] + wanted = max(four + WINDOWS_INT8_HEAD8_EXTRA, + WINDOWS_INT8_LONG_HEAD8_BASE + + int(WINDOWS_INT8_LONG_HEAD8_PER_ROW * (effective_tokens - WINDOWS_INT8_HEAD8_TOKENS))) + reserves = tuple((heads, max(need, wanted - small_adjustment) if heads == 8 else need) + for heads, need in reserves) if windows_int8_full: four = dict(SMALL_ACTIVATION_RESERVES)[4] extra = int(WINDOWS_INT8_HEAD8_EXTRA * effective_tokens / WINDOWS_INT8_HEAD8_TOKENS) @@ -1195,7 +1222,7 @@ def held(group): selected = (head, activation_bytes(head, effective_tokens) - GPU_ATTENTION_OUTPUT_BYTES) else: available = [(heads, need) for heads, need in reserves - if heads <= 4 or not desktop or ((windows_small_head8 or windows_int8_full) + if heads <= 4 or not desktop or ((windows_small_head8 or windows_int8_full or windows_int8_long) and gpu_budget >= need + small_adjustment + GPU_ATTENTION_OUTPUT_BYTES)] selected = next(((heads, need + small_adjustment) for heads, need in available if gpu_budget >= need + small_adjustment), None) From f6f420784169dc93f7e69fd03a1e30d81d82397d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:28:45 +0000 Subject: [PATCH 04/18] Eight heads and a second transfer slot for FP8 first passes in the Windows small-memory band --- freevideo_engine/policy.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 9754a8f4..01d4b231 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -117,6 +117,14 @@ # 960x544x243 went from head 4 with one block (10.37 s/step, 4.68 GiB) to # head 8 with none (8.43 s, 4.18 GiB). WINDOWS_INT8_HEAD8_TOKENS = 72576 +# Native FP8 (Ada and Blackwell) takes the same rule up to a two-pass first +# pass. On a Blackwell card held to an 8 GiB laptop's 6.48 GiB allocator limit +# with the Windows allocator (no expandable segments), 832x448x243 (26208 rows) +# with two resident blocks and the second slot peaked 4.74 GiB reserved with +# four heads and 5.03 with eight, 0.29 GiB against the 0.42 the rule charges at +# this length. +# Full-resolution FP8 stays on four heads until it is measured. +WINDOWS_FP8_HEAD8_TOKENS = 4 * 9072 WINDOWS_INT8_HEAD8_EXTRA = int(1.15 * GiB) # Beyond that row count the four-head plan stayed on 12 GB cards although the # budget covers eight heads. Reserved peak of eight heads and no resident @@ -1186,10 +1194,11 @@ def held(group): and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES and effective_tokens <= WINDOWS_SMALL_HEAD8_TOKENS) - windows_int8_full = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] + windows_int8_full = (desktop and not lora and legs == ['sage2', 'sage2'] and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES - and WINDOWS_SMALL_HEAD8_TOKENS < effective_tokens <= WINDOWS_INT8_HEAD8_TOKENS) + and WINDOWS_SMALL_HEAD8_TOKENS < effective_tokens + <= (WINDOWS_INT8_HEAD8_TOKENS if int8 else 0 if ampere else WINDOWS_FP8_HEAD8_TOKENS)) windows_int8_staged_head8 = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES From 8d1058365ae31ca51c6ae11c738eae2eef3773c0 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:28:52 +0000 Subject: [PATCH 05/18] Editor: never send an empty description when continuing a finished video --- web/editor.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/editor.js b/web/editor.js index 5055fab9..607eb0cb 100644 --- a/web/editor.js +++ b/web/editor.js @@ -823,9 +823,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given }, needsText(selection = state.selection) { const range = selectionRange(selection); if (!range) return true; - // Only a part pulled out of a finished one needs no description: it continues that one. + // Only a part pulled out of a finished one needs no description: it continues that one with the + // description it took over. A finished video without one (an exported edit) still needs it. const head = state.record.tracks[0].clips.find(clip => clip.start === range.start); - const before = !!head && model.continuesBefore(state.record, head.id) && state.record.tracks[0].clips.some(clip => + const before = !!head && !!head.text?.trim() && model.continuesBefore(state.record, head.id) && state.record.tracks[0].clips.some(clip => clip.start + clip.frames === range.start && clip.source && clip.extend && clip.in + clip.frames === clip.length); return !before; }, @@ -966,6 +967,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given text = found.text; used = found.before ? t('Used the description of the part before.', '沿用了上一段的描述。') : t('Used the latest description.', '沿用了最近一次的描述。'); state.setRangeText(text); genbar?.setText(text); + } else if (!text.trim()) { + // The box was emptied; the part keeps the description it took over, as an empty one cannot be generated. + text = state.record.tracks[0].clips.find(clip => clip.start === selectionRange(selection).start).text; + state.setRangeText(text); genbar?.setText(text); } const waiting = waitingFor(selection); if (waiting) { From 603911d6b08f2e0148fbb4d47acefa79cbac750a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:28:57 +0000 Subject: [PATCH 06/18] Prompt enhancement retries missing media and names the fallback reason --- freevideo_engine/prompt_vlm/rules.py | 1 + freevideo_engine/prompt_vlm/service.py | 5 ++-- freevideo_engine/prompt_vlm/worker.py | 32 +++++++++++++++----------- web/prompt_enhance.js | 24 +++++++++++++++++-- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py index 8d4e8a60..1f36d939 100644 --- a/freevideo_engine/prompt_vlm/rules.py +++ b/freevideo_engine/prompt_vlm/rules.py @@ -143,6 +143,7 @@ def __init__(self, condition, text, value): indices = {int(i) for i in re.findall(r'', draft)} self.details = dict(condition=condition, output_characters=len(draft), missing_pictures=sorted(i for i in pictures if f'' not in draft), + missing_references=[each for each in required if each not in draft], out_of_range_pictures=sorted(i for i in indices if i not in pictures), retried=False) diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index 4e8fc642..b8f6bee0 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -434,7 +434,7 @@ def work(self, root, machine, action, value): self.progress(dict(phase='cancelled')) self.result_expires = time.monotonic() + 600 # Allowlisted metadata only, never original/rewritten text or input paths. - # Output rejections contain condition IDs, character counts, picture numbers and retry flags only. + # Output rejections contain condition IDs, counts, typed media labels and retry flags only. report = {key: item for key, item in self.state.items() if key in ('phase', 'error', 'exception', 'error_details', 'timings', 'diagnostics', 'resources', 'cleanup', 'worker_returncode', 'output_rejection')} @@ -609,7 +609,8 @@ def read_rows(): rules.validate_output(result.get('text'), value) except rules.InvalidOutput as error: stats = result.get('diagnostics') or {} - error.details['retried'] = bool(stats.get('picture_retry') or stats.get('dialogue_retry')) + error.details['retried'] = bool(stats.get('media_retry') or stats.get('picture_retry') + or stats.get('dialogue_retry')) self.progress(dict(diagnostics=stats)) raise self.progress(dict(result, worker_returncode=child.returncode)) diff --git a/freevideo_engine/prompt_vlm/worker.py b/freevideo_engine/prompt_vlm/worker.py index 0b6f97ec..3d8bfbe4 100644 --- a/freevideo_engine/prompt_vlm/worker.py +++ b/freevideo_engine/prompt_vlm/worker.py @@ -420,19 +420,19 @@ def on_token(timing): text = processor.decode(ids, skip_special_tokens=True) missing = missing_lines(text, value['text']) rewritten, rejected = None, None - pictures = [] + references = [] try: rewritten = validate_rewrite(text, value, stats) except InvalidOutput as error: rejected = error if error.details['condition'] == 'missing_picture_reference': - pictures = error.details['missing_pictures'] - if missing or pictures: + references = error.details['missing_references'] + if missing or references: # Both corrections share one turn and the existing context/cache allowance. correction = '' - if pictures: - labels = ', '.join(f'' for i in pictures) - correction = (f'Your rewrite omitted these required image references: {labels}. ' + if references: + labels = ', '.join(references) + correction = (f'Your rewrite omitted these required media references: {labels}. ' 'Preserve all existing content and add each missing reference where it belongs. ' 'Return the complete prompt in the same required section format.\n') if missing: @@ -445,24 +445,28 @@ def on_token(timing): stats.update(retry_output_tokens=len(again), rewrite_seconds=time.monotonic() - loaded) if missing: stats['dialogue_retry'] = True - if pictures: - stats['picture_retry'] = True + if references: + stats['media_retry'] = True + if any(each.startswith('= 1800: + if references and len(again) >= 1800: error = InvalidOutput('output_token_limit', second, value) error.details['retried'] = True stats['output_rejections'].append(error.details) raise error - if len(again) < 1800 and (pictures or len(missing_lines(second, value['text'])) < len(missing)): + if len(again) < 1800 and (references or len(missing_lines(second, value['text'])) < len(missing)): rewritten = candidate except InvalidOutput: - if pictures: + if references: raise - elif pictures: - stats['picture_retry_skipped'] = stats.get('dialogue_retry_skipped', 'context_length') + elif references: + stats['media_retry_skipped'] = stats.get('dialogue_retry_skipped', 'context_length') + if any(each.startswith(' String(Math.round(value / 2 ** 30)); + // Unknown codes keep the server's own reason, without paths, so the user can quote it. + const reasonOf = error => String(error?.message || '').replace(/[A-Za-z]:[\\/][^\s'"]*|\/[^\s'"]+\/[^\s'"]*/g, '').replace(/\s+/g, ' ').trim().slice(0, 120); const errorText = error => ({ busy: t('Finish the current queue before enhancing this prompt.', '请等待当前队列完成后再改写。'), prompt_required: t('Type a prompt first; enhancement rewrites it before generating.', '请先输入提示词,生成前会自动改写。'), @@ -142,7 +144,25 @@ export function createPromptEnhancer({node, input, editor, api, context, setText stale: t('The prompt or references changed. Rewrite again for the new scene.', '原文或素材已改变,请按新内容重新改写。'), cancelled: t('Cancelled. Your original prompt is retained.', '已取消,原文已保留。'), worker_cleanup_failed: t('The rewrite process could not close. Restart FreeVideo before generating.', '改写进程未能退出,请重启 FreeVideo 后再生成。'), - }[error?.message] || t('Could not enhance the prompt. Retry or switch enhancement off to use the original.', '暂时无法改写,请重试,或关闭增强使用原文。')); + }[error?.message] || t(`Could not enhance the prompt${reasonOf(error) ? `: ${reasonOf(error)}` : ''}. Retry or switch enhancement off to use the original.`, + `暂时无法改写${reasonOf(error) ? `。原因:${reasonOf(error)}。` : ','}请重试,或关闭增强使用原文。`)); + // After falling back the original is already in use, so only the reason and the next useful step are shown. + const fallbackText = error => ({ + busy: t('The prompt cannot be rewritten while the queue is running.', '队列未完成时不能改写。'), + setup_required: t('Rewriting is available after FreeVideo installation is complete.', 'FreeVideo 安装完成后才能改写。'), + model_missing: t('Rewriting needs the optional model. Download it to enable rewriting.', '改写需要可选模型,下载后可用。'), + download_failed: t('The optional model download paused. Retry to resume.', '可选模型下载已暂停,重试可继续下载。'), + disk_space: t('Not enough disk space for the optional model.', '磁盘空间不足,无法安装可选模型。'), + ram_space: t(`The rewrite model needs about ${gib(error?.ramBytes || ramBytes)} GiB of available RAM to load.`, `改写模型加载需约 ${gib(error?.ramBytes || ramBytes)} GiB 可用内存。`), + invalid_prompt: t('Rewriting supports prompts of up to 12,000 characters.', '改写只支持不超过 12,000 字符的提示词。'), + invalid_output: t('The rewrite was incomplete.', '改写结果不完整。'), + context_length: t('The scene is too long for the rewrite model.', '内容超出改写模型容量。'), + out_of_memory: t('Not enough memory to rewrite. Close other GPU apps before rewriting again.', '改写时内存不足,关闭其他显卡程序后可再改写。'), + timeout: t('Rewriting took too long.', '改写超时。'), + unsupported_media: errorText(error), too_many_images: errorText(error), stale: errorText(error), + connected_media: errorText(error), audio_media: errorText(error), unreadable_video: errorText(error), + worker_cleanup_failed: errorText(error), + }[error?.message] || (reasonOf(error) ? t(`The rewrite failed: ${reasonOf(error)}.`, `改写失败,原因:${reasonOf(error)}。`) : t('The rewrite failed.', '改写失败。'))); function save(changed = true) { node.properties ||= {}; node.properties.freevideo_prompt_versions = {...versions.value}; @@ -278,7 +298,7 @@ export function createPromptEnhancer({node, input, editor, api, context, setText function useOriginal(error) { console.warn('FreeVideo prompt rewrite failed; using the original prompt.', error?.message); versions.value.selected = 'original'; apply(); - show(t('Could not enhance; generated with the original.', '没能改写,已用原文生成。'), 'note'); + show(t(`Generated with the original prompt. ${fallbackText(error)}`, `已用原文生成。${fallbackText(error)}`), 'note'); } return {element, async beforeGenerate() { if (running) { From 7e19ac7311cd31b562173fd0a71312213ab941c5 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:41:24 +0000 Subject: [PATCH 07/18] Pinned host copies fall back to a plain buffer after a CUDA copy error --- freevideo_engine/activation_staging.py | 74 +++++++++++++++++++++++++- freevideo_engine/decode_resume.py | 2 +- freevideo_engine/head_chunk.py | 16 ++++-- freevideo_engine/pass_configuration.py | 14 +++-- freevideo_engine/residual.py | 39 ++++++++++++-- freevideo_engine/runtime.py | 3 ++ freevideo_engine/two_pass_metrics.py | 1 + 7 files changed, 135 insertions(+), 14 deletions(-) diff --git a/freevideo_engine/activation_staging.py b/freevideo_engine/activation_staging.py index 9d2f2cd1..48c8c09d 100644 --- a/freevideo_engine/activation_staging.py +++ b/freevideo_engine/activation_staging.py @@ -1,6 +1,75 @@ """Contiguous D2H head groups with double-buffered CPU projection tiles.""" +import collections import torch +# Every refused locked-page copy of this process, residual stream and head +# readouts alike, so a report shows whether the fallback fired, which copy was +# refused and what CUDA said. The newest entries are kept; the count is exact. +_REFUSED = collections.deque(maxlen=32) +_refused_total = 0 + + +def record_refusal(source, copy, error): + global _refused_total + lines = str(error).strip().splitlines() + _REFUSED.append(dict(source=source, copy=copy, error=(lines[0] if lines else '')[:200])) + _refused_total += 1 + + +def refusals_mark(): + return _refused_total + + +def refusals_since(mark): + """Refusals recorded after `mark`, as plain dicts for the sampling metrics.""" + count = min(_refused_total - mark, len(_REFUSED)) + return [dict(row) for row in list(_REFUSED)[len(_REFUSED) - count:]] if count > 0 else [] + + +class LockedCopies: + """Asynchronous copies through page-locked buffers that continue in pageable memory. + + Windows can refuse a queued copy with "invalid argument" when the system + takes locked pages back. The queued work is finished first, so a sticky + error is raised as itself rather than hidden by the retry. The copy then + goes through a pageable temporary, which holds the same bytes, and the + remaining copies of these buffers skip the locked path. A failed device + allocation is not a refusal: it is raised for the caller to recover from, + and the buffers stay locked. + """ + + def __init__(self): + self.locked = True + self.fallbacks = 0 + + def _leave(self, device, copy, error): + record_refusal('readout', copy, error) + if device.type == 'cuda': + torch.cuda.synchronize(device) + self.locked = False + self.fallbacks += 1 + + def to_host(self, destination, source): + if self.locked: + try: + destination.copy_(source, non_blocking=True) + return + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + self._leave(source.device, 'to_host', error) + destination.copy_(source.to('cpu')) + + def to_device(self, source, device): + if self.locked: + try: + return source.to(device, non_blocking=True) + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + self._leave(torch.device(device), 'to_device', error) + return source.clone().to(device) + class HeadReadouts: def __init__(self, rows, head_widths, dtype, tile_rows): @@ -11,6 +80,7 @@ def __init__(self, rows, head_widths, dtype, tile_rows): self.groups = [torch.empty((self.capacity, width), dtype=dtype, pin_memory=True) for width in head_widths] self.tiles = [torch.empty((tile_rows, sum(head_widths)), dtype=dtype, pin_memory=True) for _ in range(2)] self.events = [None, None] + self.copies = LockedCopies() def supports(self, rows, head_widths, dtype, tile_rows): return rows <= self.capacity and tuple(head_widths) == self.widths and dtype == self.dtype and tile_rows == self.tile_rows @@ -18,7 +88,7 @@ def supports(self, rows, head_widths, dtype, tile_rows): def store(self, group, values): if values.shape[1] != self.widths[group] or not values.is_contiguous(): raise ValueError('Head readouts must be contiguous [rows, head channels]') - self.groups[group][:len(values)].copy_(values, non_blocking=True) + self.copies.to_host(self.groups[group][:len(values)], values) def project(self, module, output, rows): # The caller synchronizes once after all D2H groups have been written. @@ -33,7 +103,7 @@ def project(self, module, output, rows): for group, width in zip(self.groups, self.widths): tile[:, offset:offset + width].copy_(group[start:start + count]) offset += width - device_values = tile.to(output.device, non_blocking=True) + device_values = self.copies.to_device(tile, output.device) done = torch.cuda.Event() done.record(stream) self.events[slot] = done diff --git a/freevideo_engine/decode_resume.py b/freevideo_engine/decode_resume.py index c02a964a..93a83cde 100644 --- a/freevideo_engine/decode_resume.py +++ b/freevideo_engine/decode_resume.py @@ -20,7 +20,7 @@ COMPUTE_PARTITION = frozenset(('head_chunk', 'window_batch', 'ff_chunk', 'projection_chunk', 'head_parallelism', 'attention_cpu_outputs', 'grouped_attention_outputs', 'fp8_ff_recompute', 'residual_offload', 'query_chunk')) SAMPLE_FIELDS = ('config', 'sample_seconds', 'step_seconds', 'finite_latents', 'geometry', - 'conditioning_info', 'conditioning_shape', 'offload', 'residual_offload_steps', + 'conditioning_info', 'conditioning_shape', 'offload', 'residual_offload_steps', 'locked_copy_refusals', 'attention_backend_calls', 'head_execution', 'text_refinement', 'sampling_memory', 'load_seconds', 'load_breakdown', 'load_peak_allocated_bytes', 'load_peak_reserved_bytes', 'torch_peak_allocated_bytes', 'torch_peak_reserved_bytes', 'latent_save_seconds', 'sample_metrics_scope', diff --git a/freevideo_engine/head_chunk.py b/freevideo_engine/head_chunk.py index 8cda14ea..32eec7cc 100644 --- a/freevideo_engine/head_chunk.py +++ b/freevideo_engine/head_chunk.py @@ -58,7 +58,7 @@ def release_readouts(policy): a 16 GiB machine failed cudaHostAlloc at the refine pass's first layer. """ held = [getattr(policy, name, None) for name in ('group_readouts', 'host_outputs')] - for name in ('group_readouts', 'host_outputs', 'host_output_key'): + for name in ('group_readouts', 'host_outputs', 'host_output_key', 'host_copies'): if hasattr(policy, name): delattr(policy, name) if any(value is not None for value in held): @@ -134,12 +134,14 @@ def forward(attn, x, rotary): for rows in (len(x), linear_shape[0])) soft, linear = policy.group_readouts elif cpu_outputs: + from .activation_staging import LockedCopies key = (soft_shape, linear_shape, x.dtype) if getattr(policy, 'host_output_key', None) != key: release_readouts(policy) policy.host_outputs = (torch.empty(soft_shape, dtype=x.dtype, pin_memory=True), torch.empty(linear_shape, dtype=x.dtype, pin_memory=True)) policy.host_output_key = key + policy.host_copies = LockedCopies() soft, linear = policy.host_outputs elif reuses_input(attn, x, projections): # Measured second-pass peak: the head groups, with x, both output planes, @@ -200,8 +202,10 @@ def group(start): soft.store(start // chunk, attended.flatten(1)) elif taken: soft.write(channels, attended.flatten(1)) + elif cpu_outputs: + policy.host_copies.to_host(soft[:, channels], attended.flatten(1)) else: - soft[:, channels].copy_(attended.flatten(1), non_blocking=cpu_outputs) + soft[:, channels].copy_(attended.flatten(1)) del attended if not taken: beta = torch.sigmoid(F.linear(x[video], branch.beta_proj.weight[heads])) @@ -220,8 +224,10 @@ def group(start): linear_scale.update(readout) if grouped_outputs: linear.store(start // chunk, readout.contiguous()) + elif cpu_outputs: + policy.host_copies.to_host(linear[:, linear_channels], readout) else: - linear[:, linear_channels].copy_(readout, non_blocking=cpu_outputs) + linear[:, linear_channels].copy_(readout) del raw, readout, gate, beta, text_beta # Keep the exact projection/head/window shapes. Merely widening the @@ -291,13 +297,13 @@ def group(start): out = x.new_empty((len(x), output_width)) for row in range(0, len(x), projection_chunk): rows = slice(row, row + projection_chunk) - part = soft[rows].to(x.device, non_blocking=True) + part = policy.host_copies.to_device(soft[rows], x.device) scale = row_scale(soft_scale, rows) if soft_scale is not None else None out[rows] = orig.to_out[1](project_with_scale(orig.to_out[0], part, scale)) del part for row in range(0, len(linear), projection_chunk): rows = slice(row, row + projection_chunk) - part = linear[rows].to(x.device, non_blocking=True) + part = policy.host_copies.to_device(linear[rows], x.device) scale = row_scale(linear_scale, rows) if linear_scale is not None else None out[video][rows].add_(project_with_scale(attn.to_out_linear, part, scale)) del part diff --git a/freevideo_engine/pass_configuration.py b/freevideo_engine/pass_configuration.py index 1785e6bc..0cd0c77f 100644 --- a/freevideo_engine/pass_configuration.py +++ b/freevideo_engine/pass_configuration.py @@ -1,4 +1,5 @@ """Change pass-local compute tiling while preserving loaded model weights.""" +import sys from contextlib import contextmanager @@ -62,6 +63,13 @@ def configuration(engine, selected): attention.current_plan = attention.offsets = attention.batches = None # Canvas-specific host buffers must not survive alongside the next # pass's differently shaped buffers. Immutable weights remain untouched. - for name in ('host_outputs', 'host_output_key', 'group_readouts'): - if hasattr(attention, name): - delattr(attention, name) + # Dropping them here without waiting for the device returned locked + # blocks that the pass's last copies could still be using, and the + # next pass's release then found nothing left to wait for. + from .head_chunk import release_readouts + pending = sys.exc_info()[0] + try: + release_readouts(attention) + except RuntimeError: + if pending is None: + raise diff --git a/freevideo_engine/residual.py b/freevideo_engine/residual.py index 17cc9a1e..64b2ac51 100644 --- a/freevideo_engine/residual.py +++ b/freevideo_engine/residual.py @@ -21,6 +21,7 @@ def __init__(self, tensor): self.stored = False self.closed = False self.copies = {'to_host': 0, 'to_device': 0} + self.pinned_fallbacks = 0 self.copy_wall_seconds = 0. # Set by the block loop: collect reference cycles once this block has # dropped its input (packing.install_streamed_forward). @@ -43,7 +44,15 @@ def store(self, tensor): tick = time.perf_counter() if self.host is None: self.host = self._allocate() - self.host.copy_(tensor, non_blocking=self.pinned) + try: + self.host.copy_(tensor, non_blocking=self.pinned) + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + if not self.pinned: + raise + self._leave_locked_pages(keep=False, error=error) + self.host.copy_(tensor) self.stored = True self.copies['to_host'] += 1 self.copy_wall_seconds += time.perf_counter()-tick @@ -70,12 +79,36 @@ def _allocate(self): return host return torch.empty(self.shape, dtype=self.dtype, device='cpu') + def _leave_locked_pages(self, keep, error): + # A copy through the locked buffer was refused (Windows can fail a + # queued copy with "invalid argument" when the system takes pages back). + # Finish the queued work first, so a sticky error is raised as itself + # rather than hidden by the retry, then continue in pageable memory, + # which holds the same bytes and is read back unchanged. + from .activation_staging import record_refusal + record_refusal('residual', 'to_device' if keep else 'to_host', error) + if self.device.type == 'cuda': + torch.cuda.synchronize(self.device) + host = torch.empty(self.shape, dtype=self.dtype, device='cpu') + if keep: + host.copy_(self.host) + self.host, self.pinned = host, False + self.pinned_fallbacks += 1 + def restore(self): if self.closed or not self.stored: raise RuntimeError('No complete host residual is available') tick = time.perf_counter() # copy=True also makes CPU contract checks use independent storage. - tensor = self.host.to(self.device, copy=True, non_blocking=self.pinned) + try: + tensor = self.host.to(self.device, copy=True, non_blocking=self.pinned) + except torch.cuda.OutOfMemoryError: + raise + except RuntimeError as error: + if not self.pinned: + raise + self._leave_locked_pages(keep=True, error=error) + tensor = self.host.to(self.device, copy=True) self.copies['to_device'] += 1 self.copy_wall_seconds += time.perf_counter()-tick return tensor @@ -89,7 +122,7 @@ def replace(self, tensor): def stats(self): host_bytes = self.host.numel()*self.host.element_size() if self.host is not None else 0 return dict(host_buffer_bytes=host_bytes, pinned_host_bytes=host_bytes if self.pinned else 0, - copies=dict(self.copies), blocking_copy_wall_seconds=self.copy_wall_seconds, + copies=dict(self.copies), pinned_fallbacks=self.pinned_fallbacks, blocking_copy_wall_seconds=self.copy_wall_seconds, timing_scope=('Locked-page copies are queued asynchronously; this is the time spent issuing them.' if self.pinned else 'Blocking copy calls can include waiting for prior compute; not pure PCIe transfer time.')) diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index 3c21b356..bd3db93f 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -795,6 +795,8 @@ def append(self, value): pass_cache = dict(gpu_budget_bytes=pass_cache_budget_bytes, admitted_bytes=0, requested_resident_blocks=pass_resident_blocks) residual_steps = [] + from .activation_staging import refusals_mark, refusals_since + refusals_before = refusals_mark() attention_before = dict(self.attention.backend_calls) if self.attention is not None else None gemm_before = None if self.config.get('fp8_gemm'): @@ -866,6 +868,7 @@ def append(self, value): # weights through VAE loading. Reports own plain numbers only. sampled = {'sample_seconds': time.perf_counter() - started, 'step_seconds': list(step_seconds), 'offload': stats, 'residual_offload_steps': residual_steps, + 'locked_copy_refusals': refusals_since(refusals_before), 'pass_cache_admission': pass_cache, 'compute_configuration': {key: planned.get(key) for key in ('head_chunk', 'window_batch', 'ff_chunk', 'projection_chunk', 'prefetch', diff --git a/freevideo_engine/two_pass_metrics.py b/freevideo_engine/two_pass_metrics.py index a9ff1eb0..daa7074e 100644 --- a/freevideo_engine/two_pass_metrics.py +++ b/freevideo_engine/two_pass_metrics.py @@ -29,6 +29,7 @@ def combine(first, second, upscale, sampling_plan): result[key] = ({name: sum(row.get(name, 0) for row in rows) for name in set().union(*(row.keys() for row in rows))} if rows else None) result['residual_offload_steps'] = list(first.get('residual_offload_steps', [])) + list(second.get('residual_offload_steps', [])) + result['locked_copy_refusals'] = list(first.get('locked_copy_refusals', [])) + list(second.get('locked_copy_refusals', [])) if first.get('head_execution') and second.get('head_execution'): result['head_execution'] = dict(second['head_execution']) for name in ('parallel_head_calls', 'parallel_head_warmups'): From 65b3edcd76e7a53d8c53ae7d543db384bdc67116 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:42:20 +0000 Subject: [PATCH 08/18] A missing or failing nvidia-smi falls back to the CUDA driver reading --- freevideo_engine/bootstrap.py | 52 +++++++++++++--- freevideo_engine/cuda_inventory.py | 97 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 freevideo_engine/cuda_inventory.py diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index bf049977..414a2878 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -301,16 +301,46 @@ def digest(path, algorithm='sha256', git_blob=False): def inventory(gpu=None): - smi = nvidia_smi() query = 'index,uuid,name,compute_cap,memory.total,memory.free,driver_version,pci.bus_id' - result = subprocess_output.run_external([smi, '--query-gpu=' + query, '--format=csv,noheader,nounits'], - check=True, capture_output=True, timeout=20) - rows = [dict(zip(query.split(','), map(str.strip, row))) for row in csv.reader(result.stdout.splitlines())] selector = gpu if gpu is not None else os.environ.get('CUDA_VISIBLE_DEVICES', '0').split(',')[0] - matches = [r for r in rows if r['index'] == selector or r['uuid'] == selector] - if len(matches) != 1: - raise ValueError('Select one physical GPU by nvidia-smi index or full UUID with --gpu. MIG is not supported.') - selected = matches[0] + detection = {} + try: + smi = nvidia_smi() + result = subprocess_output.run_external([smi, '--query-gpu=' + query, '--format=csv,noheader,nounits'], + check=True, capture_output=True, timeout=20) + rows = [dict(zip(query.split(','), map(str.strip, row))) for row in csv.reader(result.stdout.splitlines())] + if not rows or any(set(row) != set(query.split(',')) for row in rows): + raise ValueError('nvidia-smi returned an incomplete GPU inventory') + # Unsupported numeric fields are also a failed observation, not proof + # that the user has no GPU. Only the selected row has to be readable; + # another GPU reporting N/A must not block an installation that worked. + matches = [r for r in rows if r['index'] == selector or r['uuid'] == selector] + if len(matches) == 1: + row = matches[0] + float(row['memory.total']), float(row['memory.free']) + int(row['driver_version'].split('.')[0]) + capability = tuple(map(int, row['compute_cap'].split('.'))) + if len(capability) != 2 or capability[0] <= 0 or not 0 <= capability[1] <= 9: + raise ValueError('nvidia-smi returned an unknown compute capability: ' + row['compute_cap']) + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error: + from . import cuda_inventory + try: + fallback = cuda_inventory.probe(selector) + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as fallback_error: + raise RuntimeError('GPU detection failed. nvidia-smi: %s; CUDA: %s. ' + 'Check device access and the NVIDIA driver, then retry setup.' % + (error, fallback_error)) from fallback_error + selected, rows = fallback['selected_gpu'], fallback['gpus'] + detection = dict(cuda_driver_api_version=fallback['cuda_driver_api_version'], + warnings=[cuda_inventory.WARNING], + gpu_detection=dict(source='cuda-driver', nvidia_smi_error=str(error), + nvidia_smi_returncode=getattr(error, 'returncode', None), + nvidia_smi_stderr=getattr(error, 'stderr', None))) + logging.getLogger(__name__).info('%s nvidia-smi: %s', cuda_inventory.WARNING, error) + else: + if len(matches) != 1: + raise ValueError('Select one physical GPU by nvidia-smi index or full UUID with --gpu. MIG is not supported.') + selected = matches[0] try: capability = tuple(map(int, selected['compute_cap'].split('.'))) if len(capability) != 2: @@ -332,7 +362,7 @@ def inventory(gpu=None): 'swap_free_bytes': ram.get('swap_free_bytes'), 'system_memory': ram, 'compiler': shutil.which('cl' if platform.system() == 'Windows' else 'g++'), 'git': usable_git(), **({} if platform.system() == 'Windows' else {'make': shutil.which('make')}), - 'git_found': shutil.which('git')} + 'git_found': shutil.which('git'), **detection} # Windows reuses a detected Git from 2.35, the oldest tested there, and @@ -522,6 +552,7 @@ def plan(args, *, local_progress=None, stage=None): if not args.hardware_json and (platform.system() not in ('Linux', 'Windows') or platform.machine().lower() not in ('x86_64', 'amd64')): raise RuntimeError('One-click setup supports Linux x86_64 and native Windows x64.') snapshot = json.loads(args.hardware_json.read_text(encoding='utf-8')) if args.hardware_json else inventory(args.gpu) + warnings.extend(snapshot.get('warnings', [])) hardware = Hardware.from_dict(snapshot['hardware']) errors = [] if snapshot.get('machine', '').lower() not in ('x86_64', 'amd64') or hardware.system not in ('Linux', 'Windows'): @@ -535,7 +566,8 @@ def plan(args, *, local_progress=None, stage=None): driver = str(snapshot['selected_gpu']['driver_version']).strip() # An unreadable version is left to the later checks instead of blocking setup. major = re.match(r'\d+', driver) - if major and int(major.group()) < 580: + cuda_api = snapshot.get('cuda_driver_api_version') + if cuda_api < 13000 if cuda_api is not None else (major and int(major.group()) < 580): # The card names the graphics card too; brackets would end its pattern early. model = re.sub(r'[()\r\n]', '', str(snapshot['selected_gpu'].get('name') or '')).strip() errors.append('NVIDIA driver %s%s is older than 580, the minimum for the CUDA 13 encoder; update the driver first.' diff --git a/freevideo_engine/cuda_inventory.py b/freevideo_engine/cuda_inventory.py new file mode 100644 index 00000000..193de985 --- /dev/null +++ b/freevideo_engine/cuda_inventory.py @@ -0,0 +1,97 @@ +"""Stdlib-only setup fallback when the NVML command cannot report GPU details. + +Run in a child: its temporary CUDA context must exit before installation starts. +CUDA ordinals are not nvidia-smi indices; only UUIDs identify multi-GPU selections. +""" +import ctypes as C +import json +import os +import sys +import uuid + +from . import processes, subprocess_output + + +WARNING = ('NVIDIA GPU information could not be read with nvidia-smi. CUDA detected the GPU; ' + 'you can continue. Setup will test it before marking it ready.') + + +def probe(selector=None): + env = dict(os.environ) + # Enumerate physical devices, then select by stable UUID in the worker too. + env.pop('CUDA_VISIBLE_DEVICES', None) + result = subprocess_output.run_python(processes.module_command(__name__, '0' if selector is None else selector), + env=env, capture_output=True, timeout=30) + if result.returncode: + raise RuntimeError(result.stderr.strip() or 'CUDA GPU detection exited with code %d' % result.returncode) + return json.loads(result.stdout) + + +def local_probe(selector=None): + driver = C.WinDLL('nvcuda.dll') if sys.platform == 'win32' else C.CDLL('libcuda.so.1') + + def call(name, types, *args): + function = getattr(driver, name) + function.argtypes, function.restype = types, C.c_int + status = function(*args) + if status: + raise RuntimeError('%s failed (CUDA error %d)' % (name, status)) + + integer = C.POINTER(C.c_int) + call('cuInit', [C.c_uint], 0) + version, count = C.c_int(), C.c_int() + call('cuDriverGetVersion', [integer], C.byref(version)) + call('cuDeviceGetCount', [integer], C.byref(count)) + rows = [] + for ordinal in range(count.value): + device, major, minor = C.c_int(), C.c_int(), C.c_int() + call('cuDeviceGet', [integer, C.c_int], C.byref(device), ordinal) + name, pci = C.create_string_buffer(256), C.create_string_buffer(32) + identity = C.create_string_buffer(16) + call('cuDeviceGetName', [C.c_void_p, C.c_int, C.c_int], name, len(name), device) + call('cuDeviceGetPCIBusId', [C.c_void_p, C.c_int, C.c_int], pci, len(pci), device) + call('cuDeviceGetUuid', [C.c_void_p, C.c_int], identity, device) + if hasattr(driver, 'cuDeviceGetUuid_v2'): + instance = C.create_string_buffer(16) + call('cuDeviceGetUuid_v2', [C.c_void_p, C.c_int], instance, device) + if instance.raw != identity.raw: + raise RuntimeError('MIG is not supported by setup. Select a physical GPU.') + call('cuDeviceGetAttribute', [integer, C.c_int, C.c_int], C.byref(major), 75, device) + call('cuDeviceGetAttribute', [integer, C.c_int, C.c_int], C.byref(minor), 76, device) + rows.append({'index': '', 'cuda_ordinal': ordinal, 'uuid': 'GPU-' + str(uuid.UUID(bytes=identity.raw)), + 'name': name.value.decode('utf-8', errors='replace'), 'pci.bus_id': pci.value.decode('ascii'), + 'compute_cap': '%d.%d' % (major.value, minor.value), 'driver_version': ''}) + if not rows: + raise RuntimeError('CUDA found no accessible GPUs. Check the NVIDIA driver and device access, then retry setup.') + if selector and selector.startswith('MIG-'): + raise ValueError('MIG is not supported by setup. Select a physical GPU.') + if selector and selector.startswith('GPU-'): + matches = [row for row in rows if row['uuid'] == selector] + elif len(rows) == 1 and selector in (None, '0'): + matches = rows + else: + raise ValueError('nvidia-smi indices are unavailable. Select a full GPU UUID with --gpu: ' + + ', '.join(row['uuid'] + ' (' + row['name'] + ')' for row in rows)) + if len(matches) != 1: + raise ValueError('The selected GPU UUID was not found by CUDA: ' + str(selector)) + selected = matches[0] + device = C.c_int() + call('cuDeviceGet', [integer, C.c_int], C.byref(device), selected['cuda_ordinal']) + context = C.c_void_p() + call('cuDevicePrimaryCtxRetain', [C.POINTER(C.c_void_p), C.c_int], C.byref(context), device) + try: + call('cuCtxSetCurrent', [C.c_void_p], context) + free, total = C.c_size_t(), C.c_size_t() + call('cuMemGetInfo_v2', [C.POINTER(C.c_size_t), C.POINTER(C.c_size_t)], C.byref(free), C.byref(total)) + selected.update({'memory.total': str(total.value / 2**20), 'memory.free': str(free.value / 2**20)}) + finally: + call('cuDevicePrimaryCtxRelease_v2', [C.c_int], device) + return dict(selected_gpu=selected, gpus=rows, cuda_driver_api_version=version.value) + + +if __name__ == '__main__': + try: + print(json.dumps(local_probe(sys.argv[1] if len(sys.argv) > 1 else None))) + except (OSError, RuntimeError, ValueError, AttributeError) as error: + print(str(error), file=sys.stderr) + sys.exit(1) From d6de4f9717f662e6c173508b6d3d9f4e3792ac3e Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:42:24 +0000 Subject: [PATCH 09/18] Issue template asks for the diagnostic report --- .github/ISSUE_TEMPLATE/bug_engine.yml | 9 +++++++-- .github/ISSUE_TEMPLATE/config.yml | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_engine.yml b/.github/ISSUE_TEMPLATE/bug_engine.yml index dde25157..21618163 100644 --- a/.github/ISSUE_TEMPLATE/bug_engine.yml +++ b/.github/ISSUE_TEMPLATE/bug_engine.yml @@ -15,5 +15,10 @@ body: - type: textarea id: report attributes: - label: Error or local report (optional) / 错误详情或本地报告(选填) - description: Attach the diagnostic report (FreeVideo-report-….json, or video.debug.json in the output folder) if available; it includes hardware and timing information. / 如有诊断报告(FreeVideo-report-….json,或输出文件夹里的 video.debug.json),可拖入此处;其中已包含硬件和耗时信息。 + label: Diagnostic report / 诊断报告 + description: | + In FreeVideo, select Diagnostic report under a finished video. If the generation failed, select Report a problem on the error message. The report (FreeVideo-report-….json or .zip) is saved in your browser's download folder. Drag it here. For FreeVideo before 0.3.10, use video.debug.json in the output folder. + 在 FreeVideo 中,视频生成完成后点击视频下方的「诊断报告」;生成失败时点击错误提示上的「提交问题」。报告(FreeVideo-report-….json 或 .zip)保存在浏览器的下载文件夹,请将它拖到这里。0.3.10 之前的版本使用输出文件夹里的 video.debug.json。 + placeholder: Drop FreeVideo-report-….json or .zip here / 将 FreeVideo-report-….json 或 .zip 拖到这里 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7da7eb68..88a33354 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - name: Discord url: https://discord.gg/MsA277cJzZ From f98d3db5f84c91eb7b3807b7d93a4a513383037d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 07:42:54 +0000 Subject: [PATCH 10/18] Errors with a diagnostic report submit through the report route --- web/editor_failure.js | 3 ++- web/error_panel.js | 15 +++++++++++- web/generation_progress.js | 3 ++- web/image_generation.js | 3 ++- web/issue_button.js | 15 ++++++++++-- web/report_issue.js | 21 +++++++++++++--- web/result_actions.css | 50 +++++++++++++++++++------------------- web/studio.js | 14 ++++++++--- 8 files changed, 86 insertions(+), 38 deletions(-) diff --git a/web/editor_failure.js b/web/editor_failure.js index 02df4df6..f78e8790 100644 --- a/web/editor_failure.js +++ b/web/editor_failure.js @@ -1,6 +1,6 @@ import {attemptClip, find} from './editor_model.js'; import {createErrorReport} from './error_panel.js'; -import {saveReport, showReportTip, reportIssue} from './report_issue.js'; +import {saveReport, showReportTip, focusReportTip, reportIssue} from './report_issue.js'; import {productVersion} from './updates.js'; // Receipts belong to an attempt, not to saved projects or undo history. @@ -68,6 +68,7 @@ export function createEditorFailures({api, state, jobStatuses, reasonText}) { report, async download(id, {bar, anchor, t, current = () => true}) { const found = receipt(id); if (!found) return; + if (focusReportTip(bar)) return; let blob; if (found.reportId) { const response = await api.fetchApi(`/freevideo/report/${found.reportId}`); diff --git a/web/error_panel.js b/web/error_panel.js index fbdeee1b..77124c8d 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -3,6 +3,7 @@ import {repairAction} from './repair_hint.js'; import {detectBrowserExtension, browserExtensionAdvice, extensionSteps, isNetworkTypeError, connectionLostText} from './browser_extension.js'; import {SAMPLING_EFFORTS, STEP_CACHES, UPSCALE_FACTORS, tableLevel} from './sampling_effort.js'; import {failureIssue, failureStage} from './failure_issue.js'; +import {focusReportTip, reportIssue, showReportTip} from './report_issue.js'; const reports = new WeakSet(); const captured = new WeakMap(); const diagnosticReports = new WeakSet(); @@ -441,6 +442,8 @@ function showFailureDialog(report, t) { dialog.freevideoError = identity; dialog.setAttribute('aria-label', t('FreeVideo error', 'FreeVideo 错误')); const panel = createErrorPanel(t); panel.show(report, false); + // The dialog has no run's report to download; submitting stays on the failure card. + if (diagnosticReports.has(report)) panel.element.querySelector('.fv-failure-github:not([data-lora])')?.remove(); const close = document.createElement('button'); close.type = 'button'; close.textContent = t('Got it', '知道了'); close.autofocus = true; close.onclick = () => dialog.close(); @@ -498,7 +501,9 @@ export function adviceFor(report, t, value) { return {advice, generated}; } -export function createErrorPanel(t) { +// route: where a generation's diagnostic report comes from ({download, fetch, version, has}); without it the +// button opens the feedback form. +export function createErrorPanel(t, route = null) { if (!document.getElementById('freevideo-error-style')) { const style = document.createElement('style'); style.id = 'freevideo-error-style'; style.textContent = `.fv-failure{border:1px solid var(--fv-danger,#dc7c7c);border-radius:var(--fv-r-md,12px);padding:12px 14px;margin:10px 0;text-align:left;min-width:0} @@ -562,6 +567,13 @@ export function createErrorPanel(t) { const loadIssueModules = () => import('./feedback.js').then(feedback => import('./updates.js').then(updates => { issueModules = {feedback, updates}; })).catch(() => {}); github.onclick = async () => { if (!currentReport) return; + // A generation with a diagnostic report goes the report route: save it, then the tip opens the form. + if (route?.has() && diagnosticReports.has(currentReport) && !githubLora) { + if (focusReportTip(head)) return; + try { await route.download(); } catch { return; } + showReportTip(head, github, t, {mode: 'downloaded', submit: () => reportIssue(route.fetch, route.version(), {save: false})}); + return; + } if (!issueModules) await loadIssueModules(); if (!issueModules) return; const draft = failureIssue(currentReport, element.freevideoKind, {lora: githubLora, t}); @@ -645,6 +657,7 @@ export function createErrorPanel(t) { // An int8 limit is not a judgement, so there is no mistake to report. github.hidden = !generated || advice.kind === 'lora-int8'; githubLora = lora; github.textContent = lora ? t('Report a mistake', '报告误判') : t('Report a problem', '提交问题'); + github.toggleAttribute('data-lora', lora); if (generated && typeof window !== 'undefined') loadIssueModules(); if (popup) showFailureDialog(report, t); return report; diff --git a/web/generation_progress.js b/web/generation_progress.js index 31df724b..1880718e 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -1,5 +1,5 @@ // One whole-video estimate and the same five rows in Studio and the node view. -import { saveReport, reportIssue, showReportTip } from './report_issue.js'; +import { saveReport, reportIssue, showReportTip, focusReportTip } from './report_issue.js'; import { issueButton } from './issue_button.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./generation_progress.css', import.meta.url).href; @@ -403,6 +403,7 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t async function downloadReport() { await saveReport(await fetchReport()); } report.onclick = async () => { if (report.disabled || report.hidden) return; + if (tip && report.isConnected && focusReportTip(report.parentElement)) return; problem?.remove(); const current = revision; report.disabled = true; report.textContent = t('Preparing report…', '正在整理报告…'); diff --git a/web/image_generation.js b/web/image_generation.js index 894a0eb3..05ddb97f 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -2,7 +2,7 @@ // owns its document/history; this module inserts a result through its commands. import * as commands from './image_commands.js'; import {orientMatrix, rasterKinds, scaleSelection} from './image_editor_engine.js'; -import {saveReport, reportIssue, showReportTip} from './report_issue.js'; +import {saveReport, reportIssue, showReportTip, focusReportTip} from './report_issue.js'; import {issueButton} from './issue_button.js'; import {about} from './request_assets.js'; @@ -239,6 +239,7 @@ export function renderImageFailure(host, {error, report_id, stage, fitted, info} issue.hidden = true; anchor.onclick = async () => { if (anchor.disabled) return; + if (focusReportTip(bar)) return; const restoreFocus = doc.activeElement === anchor; issue.hidden = true; anchor.disabled = true; anchor.textContent = t(...REPORT_LABELS.preparing); diff --git a/web/issue_button.js b/web/issue_button.js index 0eeaf64b..e43af07a 100644 --- a/web/issue_button.js +++ b/web/issue_button.js @@ -2,6 +2,7 @@ // shows one. A click opens the GitHub feedback form with the error sentence already in it; the report // and typed text never travel (the message goes through the same redaction as the failure card's report). import {createErrorReport} from './error_panel.js'; +import {focusReportTip, reportIssue, showReportTip} from './report_issue.js'; const MESSAGE = 300, TAIL = 1500; const ISSUE_FORM = 'https://github.com/FlashML-org/FreeVideo/issues/new'; @@ -47,8 +48,12 @@ function style() { document.head.append(sheet); } -/** The button. `source` is {where, message, kind} or a function returning it when clicked (null: nothing to send). */ -export function issueButton(t, source, {className = '', open = (url, features) => globalThis.open?.(url, '_blank', features)} = {}) { +/** + * The button. `source` is {where, message, kind} or a function returning it when clicked (null: nothing to send). + * `route` ({has, download, fetch, version}) is a run's diagnostic report: while it exists the click saves it and + * the report tip opens the form, instead of the feedback form. + */ +export function issueButton(t, source, {className = '', route = null, open = (url, features) => globalThis.open?.(url, '_blank', features)} = {}) { style(); const button = document.createElement('button'); button.type = 'button'; button.className = `fv-issue-button ${className}`.trim(); @@ -59,6 +64,12 @@ export function issueButton(t, source, {className = '', open = (url, features) = button.onclick = async () => { const value = typeof source === 'function' ? source() : source; if (!value) return; + if (route?.has()) { + if (focusReportTip(button.parentElement)) return; + try { await route.download(); } catch { return; } + showReportTip(button.parentElement, button, t, {mode: 'downloaded', submit: () => reportIssue(route.fetch, route.version(), {save: false})}); + return; + } const ready = await load(); const draft = issueDraft(value, t); // Modules that did not load still leave the blank form reachable. diff --git a/web/report_issue.js b/web/report_issue.js index e93b3143..6e7a5f73 100644 --- a/web/report_issue.js +++ b/web/report_issue.js @@ -148,6 +148,18 @@ const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(pre // saved it: Submit only opens the form). `submit` resolves to the form's // address; `onHide` runs when the user closes the card, not on a redraw; // `animate: false` redraws a card that was already showing. +// The 'downloaded' card already showing under `bar`, if any. A second press on +// Report moves focus to its button instead of saving another copy of the report. +export function focusReportTip(bar) { + const tip = bar?.parentElement?.querySelector(':scope > .fv-report-tip-downloaded:not(.fv-report-tip-leaving)'); + if (!tip) return false; + tip.querySelector('.fv-action-primary, .fv-action')?.focus({preventScroll: true}); + return true; +} +export function hideReportTip(bar) { + bar?.parentElement?.querySelector(':scope > .fv-report-tip:not(.fv-report-tip-leaving)')?.remove(); +} + export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = null, animate = true} = {}) { bar.parentElement.querySelector(':scope > .fv-report-tip:not(.fv-report-tip-leaving)')?.remove(); const tip = el('div', 'fv-report-tip'); tip.setAttribute('role', 'region'); @@ -170,7 +182,9 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = {duration: 240, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'forwards'}).finished.then(() => tip.remove(), () => tip.remove()); }; const dismiss = () => { closeForGood(); hide(); }; - close.onclick = dismiss; + // Leaving by hand returns focus to the Report button, as Esc does. + const leave = () => { dismiss(); anchor.focus(); }; + close.onclick = leave; function sent(address) { tip.classList.add('fv-report-tip-sent'); anchor.classList.remove('fv-report-attention'); mark.replaceChildren(glyph('done')); @@ -187,13 +201,14 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = link.href = address; link.target = '_blank'; link.rel = 'noopener'; note.replaceChildren(link); const done = el('button', 'fv-action fv-action-secondary', t('Done', '完成')); done.type = 'button'; - done.onclick = dismiss; + done.onclick = leave; row.replaceChildren(done); close.remove(); done.focus({preventScroll: true}); } mark.append(glyph('report')); if (mode === 'downloaded') { + tip.classList.add('fv-report-tip-downloaded'); title.textContent = t('Diagnostic report downloaded', '诊断报告已下载'); } else { title.textContent = t('Share a diagnostic report?', '欢迎提交诊断报告'); @@ -218,7 +233,7 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = tip.append(card); tip.addEventListener('keydown', event => { if (event.key !== 'Escape') return; - event.preventDefault(); event.stopPropagation(); dismiss(); anchor.focus(); + event.preventDefault(); event.stopPropagation(); leave(); }); if (mode === 'invite') anchor.classList.add('fv-report-attention'); bar.after(tip); diff --git a/web/result_actions.css b/web/result_actions.css index 42313b4f..2f0c1019 100644 --- a/web/result_actions.css +++ b/web/result_actions.css @@ -31,36 +31,36 @@ .fv-actions .fv-action-danger:hover{border-color:color-mix(in srgb,var(--fv-danger,#f09aa2) 45%,transparent);background:var(--fv-danger-subtle,#30212a);color:var(--fv-danger,#f09aa2)} /* The report card: its own row under the action bar, never over the video. Script opens and closes the row by height, so the picture above eases instead of jumping. */ -.fv-result-links>.fv-report-tip{flex:1 0 100%;box-sizing:border-box;padding-top:6px;overflow:hidden;container-type:inline-size} -.fv-result-links>.fv-report-tip .fv-report-tip-card{position:relative;display:flex;align-items:center;gap:14px;box-sizing:border-box;padding:12px 12px 12px 14px;border:1px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 28%,var(--fv-border,#2a3443));border-radius:14px;background:color-mix(in srgb,var(--fv-accent,#6db8fa) 8%,var(--fv-card,#1a222e));box-shadow:inset 0 1px 0 #ffffff0a;color:var(--fv-ink,#eee);font:13px/1.55 var(--fv-font,system-ui);text-align:left} -.fv-result-links>.fv-report-tip .fv-report-tip-card::before{content:'';position:absolute;top:-6px;left:calc(var(--fv-tip-notch,50%) - 5px);width:10px;height:10px;box-sizing:border-box;border:solid color-mix(in srgb,var(--fv-accent,#6db8fa) 28%,var(--fv-border,#2a3443));border-width:1px 0 0 1px;border-radius:2px 0 0 0;background:color-mix(in srgb,var(--fv-accent,#6db8fa) 8%,var(--fv-card,#1a222e));transform:rotate(45deg)} -.fv-result-links>.fv-report-tip .fv-report-tip-glyph,.fv-result-links>.fv-report-tip .fv-report-tip-lock{display:inline-flex;flex:none} -.fv-result-links>.fv-report-tip .fv-report-tip-glyph svg,.fv-result-links>.fv-report-tip .fv-report-tip-lock svg{width:100%;height:100%} -.fv-result-links>.fv-report-tip .fv-report-tip-mark{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:linear-gradient(150deg,color-mix(in srgb,var(--fv-accent,#6db8fa) 30%,transparent),color-mix(in srgb,var(--fv-accent,#6db8fa) 10%,transparent));color:var(--fv-accent,#6db8fa);transition:background .2s,color .2s} -.fv-result-links>.fv-report-tip .fv-report-tip-mark .fv-report-tip-glyph{width:18px;height:18px} -.fv-result-links>.fv-report-tip.fv-report-tip-sent .fv-report-tip-mark{background:linear-gradient(150deg,color-mix(in srgb,var(--fv-success,#76d9b3) 30%,transparent),color-mix(in srgb,var(--fv-success,#76d9b3) 10%,transparent));color:var(--fv-success,#76d9b3)} -.fv-result-links>.fv-report-tip .fv-report-tip-text{flex:1;min-width:0;display:grid;gap:2px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip{flex:1 0 100%;box-sizing:border-box;padding-top:6px;overflow:hidden;container-type:inline-size} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-card{position:relative;display:flex;align-items:center;gap:14px;box-sizing:border-box;padding:12px 12px 12px 14px;border:1px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 28%,var(--fv-border,#2a3443));border-radius:14px;background:color-mix(in srgb,var(--fv-accent,#6db8fa) 8%,var(--fv-card,#1a222e));box-shadow:inset 0 1px 0 #ffffff0a;color:var(--fv-ink,#eee);font:13px/1.55 var(--fv-font,system-ui);text-align:left} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-card::before{content:'';position:absolute;top:-6px;left:calc(var(--fv-tip-notch,50%) - 5px);width:10px;height:10px;box-sizing:border-box;border:solid color-mix(in srgb,var(--fv-accent,#6db8fa) 28%,var(--fv-border,#2a3443));border-width:1px 0 0 1px;border-radius:2px 0 0 0;background:color-mix(in srgb,var(--fv-accent,#6db8fa) 8%,var(--fv-card,#1a222e));transform:rotate(45deg)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-glyph,:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lock{display:inline-flex;flex:none} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-glyph svg,:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lock svg{width:100%;height:100%} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-mark{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:linear-gradient(150deg,color-mix(in srgb,var(--fv-accent,#6db8fa) 30%,transparent),color-mix(in srgb,var(--fv-accent,#6db8fa) 10%,transparent));color:var(--fv-accent,#6db8fa);transition:background .2s,color .2s} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-mark .fv-report-tip-glyph{width:18px;height:18px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip.fv-report-tip-sent .fv-report-tip-mark{background:linear-gradient(150deg,color-mix(in srgb,var(--fv-success,#76d9b3) 30%,transparent),color-mix(in srgb,var(--fv-success,#76d9b3) 10%,transparent));color:var(--fv-success,#76d9b3)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-text{flex:1;min-width:0;display:grid;gap:2px} /* Chinese breaks at its punctuation when it can; two lines come out even. */ -.fv-result-links>.fv-report-tip .fv-report-tip-title,.fv-result-links>.fv-report-tip .fv-report-tip-lead,.fv-result-links>.fv-report-tip .fv-report-tip-note{text-wrap:balance;word-break:keep-all;overflow-wrap:anywhere} -.fv-result-links>.fv-report-tip .fv-report-tip-title{font-size:13.5px;font-weight:600;line-height:1.45;color:var(--fv-ink,#eee)} -.fv-result-links>.fv-report-tip .fv-report-tip-lead{margin:0;font-size:13px;line-height:1.55;color:color-mix(in srgb,var(--fv-ink,#eee) 86%,var(--fv-muted,#a5b3c6))} -.fv-result-links>.fv-report-tip .fv-report-tip-note{display:flex;align-items:flex-start;gap:5px;margin-top:2px;font-size:12px;line-height:1.45;color:var(--fv-muted,#a5b3c6)} -.fv-result-links>.fv-report-tip .fv-report-tip-lock{width:12px;height:12px;margin-top:2.5px;opacity:.85} -.fv-result-links>.fv-report-tip .fv-report-tip-note a{display:inline;height:auto;min-height:0;margin:0;padding:0;border:0;background:none;box-shadow:none;color:var(--fv-accent,#6db8fa);font:inherit;text-decoration:none} -.fv-result-links>.fv-report-tip .fv-report-tip-note a:hover{text-decoration:underline} -.fv-result-links>.fv-report-tip .fv-report-tip-actions{flex:none;gap:6px} -.fv-result-links>.fv-report-tip .fv-report-tip-actions .fv-action{height:34px;min-height:34px;padding:0 14px;gap:6px} -.fv-result-links>.fv-report-tip .fv-report-tip-actions .fv-action .fv-report-tip-glyph{width:13px;height:13px;opacity:.85} -.fv-result-links>.fv-report-tip .fv-report-tip-close{flex:none;display:grid;place-items:center;width:30px;height:30px;margin:0;padding:0;border:0;border-radius:9px;background:none;color:var(--fv-muted,#a5b3c6);cursor:pointer;box-shadow:none} -.fv-result-links>.fv-report-tip .fv-report-tip-close:hover{background:color-mix(in srgb,var(--fv-ink,#eee) 8%,transparent);color:var(--fv-ink,#eee)} -.fv-result-links>.fv-report-tip .fv-report-tip-close:focus-visible{outline:2px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 70%,transparent);outline-offset:1px} -.fv-result-links>.fv-report-tip .fv-report-tip-close .fv-report-tip-glyph{width:14px;height:14px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-title,:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lead,:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-note{text-wrap:balance;word-break:keep-all;overflow-wrap:anywhere} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-title{font-size:13.5px;font-weight:600;line-height:1.45;color:var(--fv-ink,#eee)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lead{margin:0;font-size:13px;line-height:1.55;color:color-mix(in srgb,var(--fv-ink,#eee) 86%,var(--fv-muted,#a5b3c6))} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-note{display:flex;align-items:flex-start;gap:5px;margin-top:2px;font-size:12px;line-height:1.45;color:var(--fv-muted,#a5b3c6)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-lock{width:12px;height:12px;margin-top:2.5px;opacity:.85} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-note a{display:inline;height:auto;min-height:0;margin:0;padding:0;border:0;background:none;box-shadow:none;color:var(--fv-accent,#6db8fa);font:inherit;text-decoration:none} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-note a:hover{text-decoration:underline} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-actions{flex:none;gap:6px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-actions .fv-action{height:34px;min-height:34px;padding:0 14px;gap:6px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-actions .fv-action .fv-report-tip-glyph{width:13px;height:13px;opacity:.85} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close{flex:none;display:grid;place-items:center;width:30px;height:30px;margin:0;padding:0;border:0;border-radius:9px;background:none;color:var(--fv-muted,#a5b3c6);cursor:pointer;box-shadow:none} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close:hover{background:color-mix(in srgb,var(--fv-ink,#eee) 8%,transparent);color:var(--fv-ink,#eee)} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close:focus-visible{outline:2px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 70%,transparent);outline-offset:1px} +:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close .fv-report-tip-glyph{width:14px;height:14px} /* While the invitation shows, a dot on the Diagnostic report button ties the two together. */ .fv-actions .fv-action-report{position:relative} .fv-actions .fv-action-report.fv-report-attention::after{content:'';position:absolute;top:-3px;right:-3px;width:9px;height:9px;border-radius:50%;background:var(--fv-accent,#6db8fa);box-shadow:0 0 0 2px var(--fv-bg,#111720)} /* A narrow card keeps the text on full-width lines and moves the button below it. */ -@container (max-width:640px){.fv-result-links>.fv-report-tip .fv-report-tip-card{flex-wrap:wrap;align-items:flex-start;padding-right:40px}.fv-result-links>.fv-report-tip .fv-report-tip-text{flex:1 1 calc(100% - 50px)}.fv-result-links>.fv-report-tip .fv-report-tip-actions{margin-left:50px}.fv-result-links>.fv-report-tip .fv-report-tip-close{position:absolute;top:8px;right:8px}} -@media(prefers-reduced-motion:reduce){.fv-result-links>.fv-report-tip .fv-report-tip-mark{transition:none}} +@container (max-width:640px){:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-card{flex-wrap:wrap;align-items:flex-start;padding-right:40px}:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-text{flex:1 1 calc(100% - 50px)}:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-actions{margin-left:50px}:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-close{position:absolute;top:8px;right:8px}} +@media(prefers-reduced-motion:reduce){:is(.fv-result-links,.fv-failure,.fv-preview-column)>.fv-report-tip .fv-report-tip-mark{transition:none}} @keyframes fv-menu-enter{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} @media(prefers-reduced-motion:reduce){.fv-actions *{transition:none!important;animation:none!important}} .fv-actions[data-unavailable=true] :is(a.fv-action[download],.fv-action-extend,.fv-share-trigger){opacity:.45;cursor:default;pointer-events:none} diff --git a/web/studio.js b/web/studio.js index 2f33f86d..fde4fb0a 100644 --- a/web/studio.js +++ b/web/studio.js @@ -12,7 +12,7 @@ import { viewSwitch, viewChanged, agentTabReady, openAutopilot } from './view_na import { agentInterfaceState } from './agent_connect.js'; import { createUpdateNotice, createVersionInfo, productVersion } from './updates.js'; import { createPortNotice } from './port_notice.js'; -import { reportIssue, reportTipClosed, showReportTip } from './report_issue.js'; +import { focusReportTip, hideReportTip, reportIssue, reportTipClosed, showReportTip } from './report_issue.js'; import { issueButton } from './issue_button.js'; import { createPreviewScene } from './preview_scene.js?v=20260929-swell'; import { animateDetails, closeDialog } from './motion.js'; @@ -1211,7 +1211,7 @@ export function openStudio(node, {extendFrom = null} = {}) { showReportTip(bar, anchor, t, {mode, animate, onHide: () => { reportTipFor = null; }, submit: () => reportIssue(progress.fetchReport, productVersion(), {save: mode === 'invite'})}); } - async function downloadReport() { await progress.downloadReport(); reportCard('downloaded'); } + async function downloadReport() { if (focusReportTip(links.querySelector('.fv-actions'))) return; await progress.downloadReport(); reportCard('downloaded'); } function placeReportTip(delay) { clearTimeout(reportTipTimer); reportTipTimer = setTimeout(() => { @@ -1268,9 +1268,15 @@ export function openStudio(node, {extendFrom = null} = {}) { } const status = el('div', '', 'fv-status'); status.setAttribute('role', 'status'); // A fault in the status line carries the report button; the next text written to the line removes it. - const statusIssue = issueButton(t, () => ({where: ['Generate', '生成'], message: status.firstChild?.textContent}), {className: 'fv-status-issue'}); + // The run's diagnostic report: errors beside it go the report route instead of the feedback form. + const reportRoute = {has: () => progress.hasReport(), download: () => progress.downloadReport(), fetch: progress.fetchReport, version: productVersion}; + const statusIssue = issueButton(t, () => ({where: ['Generate', '生成'], message: status.firstChild?.textContent}), {className: 'fv-status-issue', route: reportRoute}); function fault(text) { status.dataset.error = 'true'; status.textContent = text; status.append(statusIssue); } const stats = el('div', null, 'fv-stats'), budget = el('div', '', 'fv-budget'), links = el('div', null, 'fv-result-links'); + // New text in the status line replaces the report card under the result. + const statusWatch = new MutationObserver(() => { if (!status.textContent.trim()) return; hideReportTip(links.querySelector('.fv-actions')); hideReportTip(status); }); + statusWatch.observe(status, {childList: true, characterData: true, subtree: true}); + cleanup.push(() => statusWatch.disconnect()); stats.hidden = true; const metrics = [], metricLabels = []; for (const label of [t('Generation', '生成用时'), t('Total time', '总用时'), t('Peak VRAM', '最高显存'), t('Peak RAM', '最高内存')]) { const box = el('div', null, 'fv-stat'), n = el('strong', '—'), caption = el('span', label); box.append(n, caption); stats.append(box); metrics.push(n); metricLabels.push(caption); } @@ -1303,7 +1309,7 @@ export function openStudio(node, {extendFrom = null} = {}) { const sharedGpuNote = el('div', '', 'fv-note'); sharedGpuNote.hidden = true; const encoderNote = el('div', '', 'fv-note'); encoderNote.hidden = true; output.append(status, reuseRow, trimNote, memoryNote, thermalNote, sharedGpuNote, encoderNote, links, stats, budget, progress.stall, progress.report, prewarm); - const failure = createErrorPanel(t); output.append(failure.element); + const failure = createErrorPanel(t, reportRoute); output.append(failure.element); // With the picture, the result's actions and numbers form one block, centred below the header. output.append(el('div', null, 'fv-preview-end')); if (node.freevideoFailure) failure.show(node.freevideoFailureReport || node.freevideoFailure, false); From 167ac126469b9a8bea0d24a0b2615d33e7b2bc47 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 19:51:51 +0000 Subject: [PATCH 11/18] Revert: Pinned host copies fall back to a plain buffer after a CUDA copy error --- freevideo_engine/activation_staging.py | 74 +------------------------- freevideo_engine/decode_resume.py | 2 +- freevideo_engine/head_chunk.py | 16 ++---- freevideo_engine/pass_configuration.py | 14 ++--- freevideo_engine/residual.py | 39 ++------------ freevideo_engine/runtime.py | 3 -- freevideo_engine/two_pass_metrics.py | 1 - 7 files changed, 14 insertions(+), 135 deletions(-) diff --git a/freevideo_engine/activation_staging.py b/freevideo_engine/activation_staging.py index 48c8c09d..9d2f2cd1 100644 --- a/freevideo_engine/activation_staging.py +++ b/freevideo_engine/activation_staging.py @@ -1,75 +1,6 @@ """Contiguous D2H head groups with double-buffered CPU projection tiles.""" -import collections import torch -# Every refused locked-page copy of this process, residual stream and head -# readouts alike, so a report shows whether the fallback fired, which copy was -# refused and what CUDA said. The newest entries are kept; the count is exact. -_REFUSED = collections.deque(maxlen=32) -_refused_total = 0 - - -def record_refusal(source, copy, error): - global _refused_total - lines = str(error).strip().splitlines() - _REFUSED.append(dict(source=source, copy=copy, error=(lines[0] if lines else '')[:200])) - _refused_total += 1 - - -def refusals_mark(): - return _refused_total - - -def refusals_since(mark): - """Refusals recorded after `mark`, as plain dicts for the sampling metrics.""" - count = min(_refused_total - mark, len(_REFUSED)) - return [dict(row) for row in list(_REFUSED)[len(_REFUSED) - count:]] if count > 0 else [] - - -class LockedCopies: - """Asynchronous copies through page-locked buffers that continue in pageable memory. - - Windows can refuse a queued copy with "invalid argument" when the system - takes locked pages back. The queued work is finished first, so a sticky - error is raised as itself rather than hidden by the retry. The copy then - goes through a pageable temporary, which holds the same bytes, and the - remaining copies of these buffers skip the locked path. A failed device - allocation is not a refusal: it is raised for the caller to recover from, - and the buffers stay locked. - """ - - def __init__(self): - self.locked = True - self.fallbacks = 0 - - def _leave(self, device, copy, error): - record_refusal('readout', copy, error) - if device.type == 'cuda': - torch.cuda.synchronize(device) - self.locked = False - self.fallbacks += 1 - - def to_host(self, destination, source): - if self.locked: - try: - destination.copy_(source, non_blocking=True) - return - except torch.cuda.OutOfMemoryError: - raise - except RuntimeError as error: - self._leave(source.device, 'to_host', error) - destination.copy_(source.to('cpu')) - - def to_device(self, source, device): - if self.locked: - try: - return source.to(device, non_blocking=True) - except torch.cuda.OutOfMemoryError: - raise - except RuntimeError as error: - self._leave(torch.device(device), 'to_device', error) - return source.clone().to(device) - class HeadReadouts: def __init__(self, rows, head_widths, dtype, tile_rows): @@ -80,7 +11,6 @@ def __init__(self, rows, head_widths, dtype, tile_rows): self.groups = [torch.empty((self.capacity, width), dtype=dtype, pin_memory=True) for width in head_widths] self.tiles = [torch.empty((tile_rows, sum(head_widths)), dtype=dtype, pin_memory=True) for _ in range(2)] self.events = [None, None] - self.copies = LockedCopies() def supports(self, rows, head_widths, dtype, tile_rows): return rows <= self.capacity and tuple(head_widths) == self.widths and dtype == self.dtype and tile_rows == self.tile_rows @@ -88,7 +18,7 @@ def supports(self, rows, head_widths, dtype, tile_rows): def store(self, group, values): if values.shape[1] != self.widths[group] or not values.is_contiguous(): raise ValueError('Head readouts must be contiguous [rows, head channels]') - self.copies.to_host(self.groups[group][:len(values)], values) + self.groups[group][:len(values)].copy_(values, non_blocking=True) def project(self, module, output, rows): # The caller synchronizes once after all D2H groups have been written. @@ -103,7 +33,7 @@ def project(self, module, output, rows): for group, width in zip(self.groups, self.widths): tile[:, offset:offset + width].copy_(group[start:start + count]) offset += width - device_values = self.copies.to_device(tile, output.device) + device_values = tile.to(output.device, non_blocking=True) done = torch.cuda.Event() done.record(stream) self.events[slot] = done diff --git a/freevideo_engine/decode_resume.py b/freevideo_engine/decode_resume.py index 93a83cde..c02a964a 100644 --- a/freevideo_engine/decode_resume.py +++ b/freevideo_engine/decode_resume.py @@ -20,7 +20,7 @@ COMPUTE_PARTITION = frozenset(('head_chunk', 'window_batch', 'ff_chunk', 'projection_chunk', 'head_parallelism', 'attention_cpu_outputs', 'grouped_attention_outputs', 'fp8_ff_recompute', 'residual_offload', 'query_chunk')) SAMPLE_FIELDS = ('config', 'sample_seconds', 'step_seconds', 'finite_latents', 'geometry', - 'conditioning_info', 'conditioning_shape', 'offload', 'residual_offload_steps', 'locked_copy_refusals', + 'conditioning_info', 'conditioning_shape', 'offload', 'residual_offload_steps', 'attention_backend_calls', 'head_execution', 'text_refinement', 'sampling_memory', 'load_seconds', 'load_breakdown', 'load_peak_allocated_bytes', 'load_peak_reserved_bytes', 'torch_peak_allocated_bytes', 'torch_peak_reserved_bytes', 'latent_save_seconds', 'sample_metrics_scope', diff --git a/freevideo_engine/head_chunk.py b/freevideo_engine/head_chunk.py index 32eec7cc..8cda14ea 100644 --- a/freevideo_engine/head_chunk.py +++ b/freevideo_engine/head_chunk.py @@ -58,7 +58,7 @@ def release_readouts(policy): a 16 GiB machine failed cudaHostAlloc at the refine pass's first layer. """ held = [getattr(policy, name, None) for name in ('group_readouts', 'host_outputs')] - for name in ('group_readouts', 'host_outputs', 'host_output_key', 'host_copies'): + for name in ('group_readouts', 'host_outputs', 'host_output_key'): if hasattr(policy, name): delattr(policy, name) if any(value is not None for value in held): @@ -134,14 +134,12 @@ def forward(attn, x, rotary): for rows in (len(x), linear_shape[0])) soft, linear = policy.group_readouts elif cpu_outputs: - from .activation_staging import LockedCopies key = (soft_shape, linear_shape, x.dtype) if getattr(policy, 'host_output_key', None) != key: release_readouts(policy) policy.host_outputs = (torch.empty(soft_shape, dtype=x.dtype, pin_memory=True), torch.empty(linear_shape, dtype=x.dtype, pin_memory=True)) policy.host_output_key = key - policy.host_copies = LockedCopies() soft, linear = policy.host_outputs elif reuses_input(attn, x, projections): # Measured second-pass peak: the head groups, with x, both output planes, @@ -202,10 +200,8 @@ def group(start): soft.store(start // chunk, attended.flatten(1)) elif taken: soft.write(channels, attended.flatten(1)) - elif cpu_outputs: - policy.host_copies.to_host(soft[:, channels], attended.flatten(1)) else: - soft[:, channels].copy_(attended.flatten(1)) + soft[:, channels].copy_(attended.flatten(1), non_blocking=cpu_outputs) del attended if not taken: beta = torch.sigmoid(F.linear(x[video], branch.beta_proj.weight[heads])) @@ -224,10 +220,8 @@ def group(start): linear_scale.update(readout) if grouped_outputs: linear.store(start // chunk, readout.contiguous()) - elif cpu_outputs: - policy.host_copies.to_host(linear[:, linear_channels], readout) else: - linear[:, linear_channels].copy_(readout) + linear[:, linear_channels].copy_(readout, non_blocking=cpu_outputs) del raw, readout, gate, beta, text_beta # Keep the exact projection/head/window shapes. Merely widening the @@ -297,13 +291,13 @@ def group(start): out = x.new_empty((len(x), output_width)) for row in range(0, len(x), projection_chunk): rows = slice(row, row + projection_chunk) - part = policy.host_copies.to_device(soft[rows], x.device) + part = soft[rows].to(x.device, non_blocking=True) scale = row_scale(soft_scale, rows) if soft_scale is not None else None out[rows] = orig.to_out[1](project_with_scale(orig.to_out[0], part, scale)) del part for row in range(0, len(linear), projection_chunk): rows = slice(row, row + projection_chunk) - part = policy.host_copies.to_device(linear[rows], x.device) + part = linear[rows].to(x.device, non_blocking=True) scale = row_scale(linear_scale, rows) if linear_scale is not None else None out[video][rows].add_(project_with_scale(attn.to_out_linear, part, scale)) del part diff --git a/freevideo_engine/pass_configuration.py b/freevideo_engine/pass_configuration.py index 0cd0c77f..1785e6bc 100644 --- a/freevideo_engine/pass_configuration.py +++ b/freevideo_engine/pass_configuration.py @@ -1,5 +1,4 @@ """Change pass-local compute tiling while preserving loaded model weights.""" -import sys from contextlib import contextmanager @@ -63,13 +62,6 @@ def configuration(engine, selected): attention.current_plan = attention.offsets = attention.batches = None # Canvas-specific host buffers must not survive alongside the next # pass's differently shaped buffers. Immutable weights remain untouched. - # Dropping them here without waiting for the device returned locked - # blocks that the pass's last copies could still be using, and the - # next pass's release then found nothing left to wait for. - from .head_chunk import release_readouts - pending = sys.exc_info()[0] - try: - release_readouts(attention) - except RuntimeError: - if pending is None: - raise + for name in ('host_outputs', 'host_output_key', 'group_readouts'): + if hasattr(attention, name): + delattr(attention, name) diff --git a/freevideo_engine/residual.py b/freevideo_engine/residual.py index 64b2ac51..17cc9a1e 100644 --- a/freevideo_engine/residual.py +++ b/freevideo_engine/residual.py @@ -21,7 +21,6 @@ def __init__(self, tensor): self.stored = False self.closed = False self.copies = {'to_host': 0, 'to_device': 0} - self.pinned_fallbacks = 0 self.copy_wall_seconds = 0. # Set by the block loop: collect reference cycles once this block has # dropped its input (packing.install_streamed_forward). @@ -44,15 +43,7 @@ def store(self, tensor): tick = time.perf_counter() if self.host is None: self.host = self._allocate() - try: - self.host.copy_(tensor, non_blocking=self.pinned) - except torch.cuda.OutOfMemoryError: - raise - except RuntimeError as error: - if not self.pinned: - raise - self._leave_locked_pages(keep=False, error=error) - self.host.copy_(tensor) + self.host.copy_(tensor, non_blocking=self.pinned) self.stored = True self.copies['to_host'] += 1 self.copy_wall_seconds += time.perf_counter()-tick @@ -79,36 +70,12 @@ def _allocate(self): return host return torch.empty(self.shape, dtype=self.dtype, device='cpu') - def _leave_locked_pages(self, keep, error): - # A copy through the locked buffer was refused (Windows can fail a - # queued copy with "invalid argument" when the system takes pages back). - # Finish the queued work first, so a sticky error is raised as itself - # rather than hidden by the retry, then continue in pageable memory, - # which holds the same bytes and is read back unchanged. - from .activation_staging import record_refusal - record_refusal('residual', 'to_device' if keep else 'to_host', error) - if self.device.type == 'cuda': - torch.cuda.synchronize(self.device) - host = torch.empty(self.shape, dtype=self.dtype, device='cpu') - if keep: - host.copy_(self.host) - self.host, self.pinned = host, False - self.pinned_fallbacks += 1 - def restore(self): if self.closed or not self.stored: raise RuntimeError('No complete host residual is available') tick = time.perf_counter() # copy=True also makes CPU contract checks use independent storage. - try: - tensor = self.host.to(self.device, copy=True, non_blocking=self.pinned) - except torch.cuda.OutOfMemoryError: - raise - except RuntimeError as error: - if not self.pinned: - raise - self._leave_locked_pages(keep=True, error=error) - tensor = self.host.to(self.device, copy=True) + tensor = self.host.to(self.device, copy=True, non_blocking=self.pinned) self.copies['to_device'] += 1 self.copy_wall_seconds += time.perf_counter()-tick return tensor @@ -122,7 +89,7 @@ def replace(self, tensor): def stats(self): host_bytes = self.host.numel()*self.host.element_size() if self.host is not None else 0 return dict(host_buffer_bytes=host_bytes, pinned_host_bytes=host_bytes if self.pinned else 0, - copies=dict(self.copies), pinned_fallbacks=self.pinned_fallbacks, blocking_copy_wall_seconds=self.copy_wall_seconds, + copies=dict(self.copies), blocking_copy_wall_seconds=self.copy_wall_seconds, timing_scope=('Locked-page copies are queued asynchronously; this is the time spent issuing them.' if self.pinned else 'Blocking copy calls can include waiting for prior compute; not pure PCIe transfer time.')) diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index bd3db93f..3c21b356 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -795,8 +795,6 @@ def append(self, value): pass_cache = dict(gpu_budget_bytes=pass_cache_budget_bytes, admitted_bytes=0, requested_resident_blocks=pass_resident_blocks) residual_steps = [] - from .activation_staging import refusals_mark, refusals_since - refusals_before = refusals_mark() attention_before = dict(self.attention.backend_calls) if self.attention is not None else None gemm_before = None if self.config.get('fp8_gemm'): @@ -868,7 +866,6 @@ def append(self, value): # weights through VAE loading. Reports own plain numbers only. sampled = {'sample_seconds': time.perf_counter() - started, 'step_seconds': list(step_seconds), 'offload': stats, 'residual_offload_steps': residual_steps, - 'locked_copy_refusals': refusals_since(refusals_before), 'pass_cache_admission': pass_cache, 'compute_configuration': {key: planned.get(key) for key in ('head_chunk', 'window_batch', 'ff_chunk', 'projection_chunk', 'prefetch', diff --git a/freevideo_engine/two_pass_metrics.py b/freevideo_engine/two_pass_metrics.py index daa7074e..a9ff1eb0 100644 --- a/freevideo_engine/two_pass_metrics.py +++ b/freevideo_engine/two_pass_metrics.py @@ -29,7 +29,6 @@ def combine(first, second, upscale, sampling_plan): result[key] = ({name: sum(row.get(name, 0) for row in rows) for name in set().union(*(row.keys() for row in rows))} if rows else None) result['residual_offload_steps'] = list(first.get('residual_offload_steps', [])) + list(second.get('residual_offload_steps', [])) - result['locked_copy_refusals'] = list(first.get('locked_copy_refusals', [])) + list(second.get('locked_copy_refusals', [])) if first.get('head_execution') and second.get('head_execution'): result['head_execution'] = dict(second['head_execution']) for name in ('parallel_head_calls', 'parallel_head_warmups'): From e9e8d61a027ebccf882cf9beb7bd8a40a93c7352 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 19:51:51 +0000 Subject: [PATCH 12/18] Revert: Eight heads and a second transfer slot for FP8 first passes in the Windows small-memory band --- freevideo_engine/policy.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 01d4b231..9754a8f4 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -117,14 +117,6 @@ # 960x544x243 went from head 4 with one block (10.37 s/step, 4.68 GiB) to # head 8 with none (8.43 s, 4.18 GiB). WINDOWS_INT8_HEAD8_TOKENS = 72576 -# Native FP8 (Ada and Blackwell) takes the same rule up to a two-pass first -# pass. On a Blackwell card held to an 8 GiB laptop's 6.48 GiB allocator limit -# with the Windows allocator (no expandable segments), 832x448x243 (26208 rows) -# with two resident blocks and the second slot peaked 4.74 GiB reserved with -# four heads and 5.03 with eight, 0.29 GiB against the 0.42 the rule charges at -# this length. -# Full-resolution FP8 stays on four heads until it is measured. -WINDOWS_FP8_HEAD8_TOKENS = 4 * 9072 WINDOWS_INT8_HEAD8_EXTRA = int(1.15 * GiB) # Beyond that row count the four-head plan stayed on 12 GB cards although the # budget covers eight heads. Reserved peak of eight heads and no resident @@ -1194,11 +1186,10 @@ def held(group): and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES and effective_tokens <= WINDOWS_SMALL_HEAD8_TOKENS) - windows_int8_full = (desktop and not lora and legs == ['sage2', 'sage2'] + windows_int8_full = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES - and WINDOWS_SMALL_HEAD8_TOKENS < effective_tokens - <= (WINDOWS_INT8_HEAD8_TOKENS if int8 else 0 if ampere else WINDOWS_FP8_HEAD8_TOKENS)) + and WINDOWS_SMALL_HEAD8_TOKENS < effective_tokens <= WINDOWS_INT8_HEAD8_TOKENS) windows_int8_staged_head8 = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES From b7dca0c43fd97be4ec759a909564a406775b7c10 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 19:51:51 +0000 Subject: [PATCH 13/18] Revert: Windows int8: take eight attention heads on 12 GB cards beyond 72576 rows --- freevideo_engine/policy.py | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 9754a8f4..f52ee983 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -118,22 +118,6 @@ # head 8 with none (8.43 s, 4.18 GiB). WINDOWS_INT8_HEAD8_TOKENS = 72576 WINDOWS_INT8_HEAD8_EXTRA = int(1.15 * GiB) -# Beyond that row count the four-head plan stayed on 12 GB cards although the -# budget covers eight heads. Reserved peak of eight heads and no resident -# block, complete requests with the allocator capped at 10.48 GiB and the -# Windows allocator emulated (no expandable segments), 1344x768: -# -# 72576 rows 7.04 GiB 97776 rows 9.40 107856 rows 10.23 -# -# Four heads with two blocks at 97776 rows reserved 8.81 here and 9.31 under -# Windows on an RTX 3060, so 0.5 GiB is added for the Windows allocator and -# compositor. Eight heads charge the larger of the figure below and the plan at -# 72576 rows, which keeps the requirement continuous there and never lower than -# before. Frames do not matter, only rows: the figures come from 243, 328 and -# 360 frames. -WINDOWS_INT8_LONG_HEAD8_TOKENS = 107856 -WINDOWS_INT8_LONG_HEAD8_BASE = int(7.54 * GiB) -WINDOWS_INT8_LONG_HEAD8_PER_ROW = .0937 * GiB / 1000 WINDOWS_INT8_HEAD8_AMPERE_EXTRA = int(.3 * GiB) # Below that band an 8 GiB card stages the residual stream, and the staged # pass kept four heads. With the Windows allocator (no expandable segments) @@ -1194,18 +1178,7 @@ def held(group): and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES and effective_tokens <= WINDOWS_INT8_STAGED_HEAD8_TOKENS) - windows_int8_long = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] - and effective_tokens is not None - and not (canvas.get('reference_video_tokens') or canvas.get('reference_audio_tokens')) - and WINDOWS_INT8_HEAD8_TOKENS < effective_tokens <= WINDOWS_INT8_LONG_HEAD8_TOKENS) reserves = SMALL_ACTIVATION_RESERVES - if windows_int8_long: - four = dict(SMALL_ACTIVATION_RESERVES)[4] - wanted = max(four + WINDOWS_INT8_HEAD8_EXTRA, - WINDOWS_INT8_LONG_HEAD8_BASE - + int(WINDOWS_INT8_LONG_HEAD8_PER_ROW * (effective_tokens - WINDOWS_INT8_HEAD8_TOKENS))) - reserves = tuple((heads, max(need, wanted - small_adjustment) if heads == 8 else need) - for heads, need in reserves) if windows_int8_full: four = dict(SMALL_ACTIVATION_RESERVES)[4] extra = int(WINDOWS_INT8_HEAD8_EXTRA * effective_tokens / WINDOWS_INT8_HEAD8_TOKENS) @@ -1222,7 +1195,7 @@ def held(group): selected = (head, activation_bytes(head, effective_tokens) - GPU_ATTENTION_OUTPUT_BYTES) else: available = [(heads, need) for heads, need in reserves - if heads <= 4 or not desktop or ((windows_small_head8 or windows_int8_full or windows_int8_long) + if heads <= 4 or not desktop or ((windows_small_head8 or windows_int8_full) and gpu_budget >= need + small_adjustment + GPU_ATTENTION_OUTPUT_BYTES)] selected = next(((heads, need + small_adjustment) for heads, need in available if gpu_budget >= need + small_adjustment), None) From 601d4a0aaa5681d6ce5431300de6089431205c4c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 23:54:08 +0000 Subject: [PATCH 14/18] Test persistent model check cache failure paths - Download list: files already hashed by an earlier check are not hashed again unless they change - Download list: a damaged scan memory is ignored and the check's own records win over older ledger entries --- freevideo_engine/bootstrap.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 414a2878..b2637fef 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -660,6 +660,15 @@ def plan(args, *, local_progress=None, stage=None): approved = getattr(args, 'approved_plan', None) if approved: previous_reuse = json.loads(Path(approved).read_text(encoding='utf-8')).get('local_models') + scan_cache = root / 'local-model-scan.json' + if not previous_reuse and scan_cache.is_file(): + # What an earlier check hashed; scan() still hashes any file whose size, times or inode changed. + try: + previous_reuse = json.loads(scan_cache.read_text(encoding='utf-8')) + except (OSError, ValueError): + previous_reuse = None + if not (isinstance(previous_reuse, dict) and isinstance(previous_reuse.get('matches'), dict)): + previous_reuse = None needed = [row for row in files if not model_target(row, model_dir, encoder_dir, prepared_dir).exists()] if local_manifest: if Path(local_manifest).stat().st_size > 512 * 1024: @@ -687,6 +696,11 @@ def plan(args, *, local_progress=None, stage=None): if error.__cause__ is not None: original += '\n' + str(error.__cause__) raise DownloadListError('read', original_error=original) from error + if local_reuse['matches'] and root.is_dir(): + try: + save(scan_cache, dict(root=local_reuse['root'], matches=local_reuse['matches'])) + except OSError: + pass # The next check hashes again; nothing depends on this file. local_reuse['copy_mode'] = bool(getattr(args, 'copy_existing_models', False)) local_reuse['copy_bytes'] = local_reuse['linked_bytes'] = 0 for row in needed: @@ -705,6 +719,16 @@ def plan(args, *, local_progress=None, stage=None): check_download_list = bool(getattr(args, 'download_list', False)) stamps_path = root / 'verified-models.json' stamps = json.loads(stamps_path.read_text(encoding='utf-8')) if check_download_list and stamps_path.is_file() else {} + # The check leaves the installer's ledger alone and keeps what it hashed in a file of its own. + checked_path = root / 'download-list-checks.json' + try: + checked = json.loads(checked_path.read_text(encoding='utf-8')) if check_download_list and checked_path.is_file() else {} + except (OSError, ValueError): + checked = {} + ledger_stamps = dict(stamps) + # A record this check made is newer than the ledger's for the same file whenever the two differ. + stamps = dict(ledger_stamps, **(checked if isinstance(checked, dict) else {})) + checked_paths = set() for row in files: path = model_target(row, model_dir, encoder_dir, prepared_dir) replacement = model_repair.replacement(path, row, mismatches, reserved) @@ -717,6 +741,7 @@ def plan(args, *, local_progress=None, stage=None): from .provision import verified hf_directory = Path(prepared_dir) if row.get('prepared') else model_dir if row['repo'].startswith('OpenVDN/') else encoder_dir size_matches = verified(path, row, stamps, mode=getattr(args, 'verify', 'auto'), hf_directory=hf_directory) + checked_paths.add(str(path)) present += row['bytes'] if size_matches else 0 device = existing_parent(path.parent) entry = groups.setdefault(str(device), {'path': str(device), 'needed_bytes': 0, @@ -729,6 +754,12 @@ def plan(args, *, local_progress=None, stage=None): # Only the streaming strategy can reuse this contiguous prefix. # Its completed content must still pass the pinned hash check. entry['resume_bytes'] = entry.get('resume_bytes', 0) + partial.stat().st_size + remembered = {name: value for name, value in stamps.items() if name in checked_paths and ledger_stamps.get(name) != value} + if check_download_list and root.is_dir() and remembered != checked: + try: + save(checked_path, remembered) # The next check does not hash these files again unless they change. + except OSError: + pass # Stream directly into final FP8 groups. The pinned model occupies ~45.3 # GiB; allow 52 GiB including the largest group in progress. Never credit # future source deletion toward the space needed to complete conversion. From bdce0aa66892188686e239887f7dc9e18f7c4d9c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 23:54:08 +0000 Subject: [PATCH 15/18] Test queued chat edit notices and retry recovery - Show a queued message that was edited as a calm notice without the report button --- web/autopilot_chat.css | 1 + web/autopilot_chat.js | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 014d7d44..91d27920 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -172,6 +172,7 @@ .fv-chat-delete-actions{display:flex;flex-wrap:wrap;gap:4px} .fv-chat-me>.fv-chat-actions{margin-top:6px;flex-wrap:wrap;justify-content:flex-end} .fv-chat-me .fv-chat-error{flex:1;min-width:0} +.fv-chat-error.fv-chat-calm{color:var(--fv-muted)} .fv-chat-keep{white-space:nowrap} .fv-chat-scene-menu{box-sizing:border-box;justify-self:center;width:100%;max-width:720px;display:grid;gap:2px;padding:6px;margin-bottom:6px;border:1px solid var(--fv-border);border-radius:12px;background:var(--fv-raised)} .fv-chat-scene-menu[hidden]{display:none} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 3526d822..b66970a9 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -719,8 +719,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () status.append(error, retry, report); item.append(status); } quoted(error, entry.error || ''); error.hidden = !entry.error; - // Waiting for the agent to come back is not a fault. - report.hidden = !entry.error || !!entry.automatic; + // Waiting for the agent to come back, or the user editing a queued message, is not a fault. + const calm = !!entry.automatic || entry.errorCode === 'edited'; + report.hidden = !entry.error || calm; + error.classList.toggle('fv-chat-calm', calm); error.setAttribute('role', calm ? 'status' : 'alert'); const full = entry.errorCode === 'session_full'; retry.textContent = full ? t('New chat', '新对话') : t('Retry', '重试'); retry.disabled = entry.sending || (full ? creating : !sendingAllowed()); @@ -1212,7 +1214,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () saveDraft(); // Editing a queued draft cancels its automatic delivery. Its original message remains available for manual retry. for (const entry of pending) if (entry.automatic && entry.session === draftSession && entry.draft !== drafts.get(draftSession)) { - entry.automatic = false; + entry.automatic = false; entry.errorCode = 'edited'; entry.error = t('The message changed. Click "Retry" to send the original.', '消息已修改,点“重试”发送原消息。'); say(''); } From c4b91c753eee9a053f927b76b99446dceb7ad577 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 23:54:08 +0000 Subject: [PATCH 16/18] Keep the fixed failure details of a GPU error that raised the compatibility level - Keep the failure evidence from raising on malformed details and cut file names to their last part --- freevideo_engine/adaptive.py | 1 + freevideo_engine/compatibility.py | 51 +++++++++++++++++++++++++++- freevideo_engine/resource_history.py | 2 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index 102a6ad6..234acb4c 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -624,6 +624,7 @@ def execute_attempts(history, identity, profile, canvas, launch, *, row['seconds'] = time.perf_counter() - started if isinstance(metrics, dict): row['metrics'] = attempt_metrics(metrics) + details['completed_steps'] = row['metrics']['completed_steps'] history.finish(attempt, failure['outcome'], details=details) row.update(state=failure['outcome'], failure=failure) persist() diff --git a/freevideo_engine/compatibility.py b/freevideo_engine/compatibility.py index c5066fcc..14ad4d73 100644 --- a/freevideo_engine/compatibility.py +++ b/freevideo_engine/compatibility.py @@ -78,6 +78,50 @@ def current_settings(value): return value +def failure_evidence(details): + # Bookkeeping must never turn a settled failure into another error. + try: + return _failure_evidence(details) + except Exception: + return None + + +def _failure_evidence(details): + """What a discarded GPU failure leaves for diagnosis: fixed fields and numbers only. + + The attempt row is deleted when it is discarded, so a report taken after a + later successful retry would otherwise show no more than the reason. + """ + if not isinstance(details, dict): + return None + evidence = {} + for key in ('failed_phase', 'completed_steps'): + value = details.get(key) + if isinstance(value, str) and len(value) <= 64 or type(value) is int: + evidence[key] = value + chain = [] + for item in (details.get('exception') or [])[:3]: + if not isinstance(item, dict): + continue + entry = {key: item[key] for key in ('exception_type', 'category') if isinstance(item.get(key), str) + and len(item[key]) <= 64} + if type(item.get('error_code')) is int: + entry['error_code'] = item['error_code'] + entry['frames'] = [{key: (str(frame[key]).replace('\\', '/').rsplit('/', 1)[-1] if key == 'file' else frame[key]) + for key in ('file', 'function', 'line') + if isinstance(frame.get(key), (str, int)) and len(str(frame[key])) <= 96} + for frame in (item.get('frames') or [])[-4:] if isinstance(frame, dict)] + chain.append(entry) + if chain: + evidence['exception'] = chain + memory = details.get('memory') + if isinstance(memory, dict): + numbers = {key: value for key, value in memory.items() if type(value) is int} + if numbers: + evidence['memory'] = dict(list(numbers.items())[:16]) + return evidence or None + + def device_key(identity): """Missing optional metadata disables compatibility, never invents a device.""" if not isinstance(identity, dict): @@ -174,7 +218,7 @@ def acknowledge(self, identity, notice_id): self.write_row(db, key, value) return dict(value, levels=LEVELS, available=True) - def incident(self, row, reason, batch=None): + def incident(self, row, reason, batch=None, evidence=None): # Only a launched video worker can be a load-related interrupted attempt. if row.get('purpose') != 'generation' or not row.get('worker'): return @@ -191,6 +235,9 @@ def incident(self, row, reason, batch=None): config=row['config'], geometry=row['geometry'], diagnosis='unknown', time=time.time(), policy_revision=revision, applicable=revision == policy_revision(), counted=False, level_changed=False) + evidence = failure_evidence(evidence) + if evidence: + incident['evidence'] = evidence escalate = False if value['automatic'] and incident['applicable']: if reason == 'cuda_error': @@ -225,6 +272,8 @@ def incident(self, row, reason, batch=None): value['notice'] = dict(id=row['id'], level=value['level'], reason=reason, phase=row.get('phase'), diagnosis='unknown', policy_revision=revision) value['trigger'] = dict(value['notice']) + if evidence: + value['trigger']['evidence'] = evidence db.execute('INSERT INTO incidents VALUES (?,?,?,?)', (row['id'], key, incident['time'], json.dumps(incident))) self.write_row(db, key, value) db.execute('DELETE FROM incidents WHERE device=? AND id NOT IN ' diff --git a/freevideo_engine/resource_history.py b/freevideo_engine/resource_history.py index 831f7b2d..339bfbe0 100644 --- a/freevideo_engine/resource_history.py +++ b/freevideo_engine/resource_history.py @@ -411,7 +411,7 @@ def finish(self, attempt_id, outcome, *, details=None, observation=None): if outcome == 'discarded': if (details or {}).get('kind') in ('cuda_error', 'unknown_worker_exit'): from .compatibility import Store - Store(self.path.parent).incident(self._row(row), details['kind']) + Store(self.path.parent).incident(self._row(row), details['kind'], evidence=details) db.execute('DELETE FROM attempts WHERE id=?',(attempt_id,)) return {'id':attempt_id,'outcome':'discarded'} observed = self._observation(observation, row) From 72f969429cc8d310cbba0de170d1b5e349f61fc0 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 23:54:08 +0000 Subject: [PATCH 17/18] Bundle static curl for Linux AppImage downloads - Shorten the missing curl message - Keep relative paths private and trust system roots for HTTPS proxies --- freevideo_engine/comfy_environment.py | 5 ++- freevideo_engine/diagnostics.py | 8 ++++- freevideo_engine/failure_details.py | 12 ++++++- freevideo_engine/linux_bundle.py | 15 +++++++++ freevideo_engine/modern_launcher.py | 10 ++++++ freevideo_engine/network.py | 12 +++++-- freevideo_engine/system.py | 40 ++++++++++++++++++++-- scripts/bootstrap_linux.sh | 2 +- scripts/build_linux.py | 48 ++++++++++++++++++++++++++- web/error_panel.js | 3 +- 10 files changed, 144 insertions(+), 11 deletions(-) diff --git a/freevideo_engine/comfy_environment.py b/freevideo_engine/comfy_environment.py index 4b0ea9e7..f3d1625b 100644 --- a/freevideo_engine/comfy_environment.py +++ b/freevideo_engine/comfy_environment.py @@ -35,4 +35,7 @@ def isolated_environment(root, source, environ=None): env.update(FREEVIDEO_HOME=str(root), FREEVIDEO_PROXY_MODE=proxy_mode, PYTHONPATH=str(source), PYTHONNOUSERSITE='1', PYTHONUTF8='1', PYTHONIOENCODING='utf-8', NO_COLOR='1', FREEVIDEO_UI_EVENTS='1') from .triton_compat import temporary_environment - return temporary_environment(root, env) + # Pruning sys.prefix above also prunes the frozen launcher's static curl. + # Restore its fallback for managed Python after isolating Python packages. + from .linux_bundle import child_environment + return temporary_environment(root, child_environment(env)) diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py index 9f15bc49..29e08a03 100644 --- a/freevideo_engine/diagnostics.py +++ b/freevideo_engine/diagnostics.py @@ -42,7 +42,11 @@ # The preceding character guard prevents URL slashes from being treated # as local paths while still matching a drive path after prose such as # ``at C:\\Users\\...`` or a quoted traceback location. - r'''(?]+[\\/])|/(?!/))[^"'<>;\r\n,\)\]}]*''') + # Consume explicit relative paths too, spaces included, so an interior + # slash (after e.g. a hyphen) cannot subsequently be mistaken for an + # absolute path. Only our own ``./setup.sh`` command stays as written. + r'''(?\./setup\.sh(?![\w/\\-]|\.\w))|\.{1,2}/[^"'<>;\r\n,\)\]}]*|''' + r'''(?:(?:[A-Za-z]:[\\/])|(?:\\\\[^\\/\s"'<>]+[\\/])|/(?!/))[^"'<>;\r\n,\)\]}]*)''') class Redactor: @@ -97,6 +101,8 @@ def text(self, value): @classmethod def _path_match(cls, match): value = match.group(0) + if match.group('command') is not None: + return value # Text is often redacted again (a worker's log inside a report, the # launcher terminal). The ``/name`` after an earlier ```` is that # pass's own output, unless it still has a directory in it. diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 5ebbfda2..7a5a3c2a 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -148,7 +148,17 @@ def display_launcher_error(value, *, zh=False, replacements=()): from .offline_errors import alias_failure, guidance if alias_failure(value): return guidance('runtime-alias', zh) - return redacted_launcher_error(value, replacements) + text = redacted_launcher_error(value, replacements) + if zh: + text = re.sub(r'Missing curl, needed for downloads and proxy connections\. ' + r'(In a terminal, run: ([^\r\n]+?), then check again\.|' + r'Ask your administrator to install curl, then check again\.)' + r'( You can also run \./setup\.sh in the source folder\.)?', + lambda match: '缺少 curl,下载和代理连接需要它。' + + ('请在终端运行:' + match.group(2) + ',然后重新检查。' if match.group(2) else + '请管理员安装 curl,然后重新检查。') + + ('也可以在源码目录运行 ./setup.sh。' if match.group(3) else ''), text) + return text def setup_command_failure(label, log, exit_code): diff --git a/freevideo_engine/linux_bundle.py b/freevideo_engine/linux_bundle.py index bca5a1c0..10fb1ea7 100644 --- a/freevideo_engine/linux_bundle.py +++ b/freevideo_engine/linux_bundle.py @@ -61,6 +61,21 @@ def child_environment(environ=None): env.pop(name) elif name in QT_VARIABLES and inside_bundle(env[name]): env.pop(name) + # Setup also runs through bash and a managed, non-frozen Python. Carry the + # selected fallback on PATH so those workers use system.curl_executable() + # just like the GUI. Append it: existing system tools keep priority. + from .system import curl_executable, curl_ca_arguments + bundled = bundle_directory() / 'curl/curl' + curl = curl_executable(env) if bundled.is_file() else None + if curl is not None and Path(curl) == bundled: + folder = str(Path(curl).parent) + path = env.get('PATH', os.defpath) + if folder not in path.split(os.pathsep): + env['PATH'] = path + os.pathsep + folder if path else folder + certificates = curl_ca_arguments(curl, env) + if certificates: + # curl reads no variable for proxy roots; curl_command and setup pass this one on. + env['CURL_CA_BUNDLE'] = env['FREEVIDEO_CURL_PROXY_CA'] = certificates[1] return env diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index fac53883..60a05121 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -426,6 +426,16 @@ def smoke_test(app, engine, window, bridge, destination): try: payload = check_launcher_payload(session.source, destination) payload['reopen'] = check_launcher_reopen(destination) + from .linux_bundle import frozen_linux, child_environment + if frozen_linux(): + import subprocess + from .network import curl_command + command = curl_command(5) + version = subprocess.run([command[0], '--version'], env=child_environment(), + capture_output=True, text=True, timeout=10, check=True).stdout + if 'https' not in version: + raise RuntimeError('Packaged curl lacks HTTPS support') + payload['curl'] = dict(executable=command[0], version=version.splitlines()[0]) from PySide6.QtQuick import QQuickItem for page in ('comfy', 'models', 'progress', 'launcher'): session.page = page diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 30c379a7..ea3000a0 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -25,7 +25,7 @@ from urllib.parse import quote, unquote, urlencode, urljoin, urlsplit from . import proxy from .long_paths import io_path, is_windows, path_chars, plain, plain_errors -from .system import curl_executable, missing_curl_message, windows +from .system import curl_executable, curl_ca_arguments, missing_curl_message, windows SOURCES = { @@ -156,6 +156,9 @@ def curl_command(timeout, env=None): command = [executable, '--config', '-', '--silent', '--show-error', '--no-location-trusted', '--location', '--max-redirs', '5', '--fail', '--proto', '=https,http', '--proto-redir', '=https,http', '--connect-timeout', str(timeout), '--user-agent', 'FreeVideo-Setup/1'] + command += curl_ca_arguments(executable, env) + if '--proxy-cacert' not in command and (env or {}).get('FREEVIDEO_CURL_PROXY_CA'): + command += ['--proxy-cacert', env['FREEVIDEO_CURL_PROXY_CA']] if (env or {}).get('FREEVIDEO_PROXY_ROUTE') == 'direct': command += ['--proxy', '', '--noproxy', '*'] return command @@ -579,8 +582,11 @@ def _package_failure(error): value = re.sub(r'(?i)(\b[\w-]{0,64}(?:token|password|secret|api[_-]?key)\s*[=:]\s*)[^\s,;&]+', r'\1', value) # Keep a stable path marker without exposing usernames, drives or mounts. - value = re.sub(r'(?;,)\]}]*)', - lambda match: '/' + re.split(r'[\\/]', match.group(1).rstrip('\\/'))[-1], value) + value = re.sub(r'(?\.{1,2}/[^\s"\'<>;,)\]}]*)|' + r'(?:[A-Za-z]:[\\/]|/)([^\s"\'<>;,)\]}]*))', + lambda match: match.group('relative') if match.group('relative') is not None + and not re.search(r'[\\/]', match.group('relative').split('/', 1)[1]) else + '/' + re.split(r'[\\/]', match.group(0).rstrip('\\/'))[-1], value) lines = [line.strip() for line in value.splitlines() if line.strip()] detail = '\n'.join(lines[-12:])[-4096:] if lines else 'no package-manager output' summary = next((line for line in lines if line), detail).strip()[:320] diff --git a/freevideo_engine/system.py b/freevideo_engine/system.py index 95a8d60a..4173395b 100644 --- a/freevideo_engine/system.py +++ b/freevideo_engine/system.py @@ -17,7 +17,36 @@ def curl_executable(env=None): from .curl_windows import inspect return inspect(env)['selected'] found = shutil.which('curl', path=env.get('PATH', os.defpath)) - return str(Path(found).absolute()) if found else None + if found: + return str(Path(found).absolute()) + from .linux_bundle import frozen_linux, bundle_directory + if frozen_linux(): + bundled = bundle_directory() / 'curl/curl' + if bundled.is_file() and os.access(bundled, os.X_OK): + return str(bundled) + return None + + +def curl_ca_arguments(executable, env=None): + """Use desktop trust roots for bundled curl, with its embedded Mozilla CA fallback. + + Leave system curl and explicit certificate settings alone. In particular, + corporate proxy roots installed by the administrator must remain usable. + The static binary needs neither libssl nor LD_LIBRARY_PATH from the bundle. + """ + from .linux_bundle import frozen_linux, bundle_directory + if not frozen_linux() or Path(executable) != bundle_directory() / 'curl/curl': + return [] + env = os.environ if env is None else env + if any(env.get(name) for name in ('CURL_CA_BUNDLE', 'SSL_CERT_FILE', 'SSL_CERT_DIR')): + return [] + for name in ('/etc/ssl/certs/ca-certificates.crt', # Debian / Ubuntu + '/etc/pki/tls/certs/ca-bundle.crt', # Fedora / RHEL + '/etc/ssl/ca-bundle.pem'): # openSUSE + if Path(name).is_file(): + # An https:// proxy is verified separately from the target server. + return ['--cacert', name, '--proxy-cacert', name] + return [] # curl 8.22.0's verified, embedded CA bundle. def missing_curl_message(env=None): @@ -26,7 +55,14 @@ def missing_curl_message(env=None): return failure_message(inspect(env)) if platform.system() == 'Darwin': return 'macOS curl was not found. Check /usr/bin/curl and reopen FreeVideo with curl available in PATH.' - return 'Missing curl for bounded downloads and HTTP/SOCKS proxy support. Run ./setup.sh to install it.' + env = os.environ if env is None else env + command = linux_tools_command(['curl'], which=lambda name: shutil.which(name, path=env.get('PATH', os.defpath))) + message = 'Missing curl, needed for downloads and proxy connections. %s, then check again.' % ( + 'In a terminal, run: ' + command if command else 'Ask your administrator to install curl') + source = source_root() + if source is not None and Path.cwd().resolve() == source and (source / 'setup.sh').is_file(): + message += ' You can also run ./setup.sh in the source folder.' + return message def linux_tools_command(names, which=shutil.which, root=None): diff --git a/scripts/bootstrap_linux.sh b/scripts/bootstrap_linux.sh index 32bcc153..6257f839 100644 --- a/scripts/bootstrap_linux.sh +++ b/scripts/bootstrap_linux.sh @@ -217,7 +217,7 @@ fv_download_uv() { [[ "$url" != *$'\n'* && "$url" != *$'\r'* && "$url" != *'"'* && "$url" != *"\\"* ]] || { fv_fail 'Invalid mirror URL.'; return 1; } part="$archive.partial.$(date +%s).$$.$attempt" if printf 'url = "%s"\n' "$url" | fv_with_route "$route" curl --disable --config - --fail --location --silent --show-error \ - --connect-timeout 5 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 1 \ + ${FREEVIDEO_CURL_PROXY_CA:+--proxy-cacert "$FREEVIDEO_CURL_PROXY_CA"} --connect-timeout 5 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 1 \ --retry-max-time 130 --output "$part" >>"$FREEVIDEO_BOOTSTRAP_ROOT/bootstrap.log" 2>&1; then if printf '%s %s\n' "$FV_UV_SHA256" "$part" | sha256sum --check --status; then mv -- "$part" "$archive" diff --git a/scripts/build_linux.py b/scripts/build_linux.py index 97122569..ac0573c8 100644 --- a/scripts/build_linux.py +++ b/scripts/build_linux.py @@ -18,6 +18,7 @@ import subprocess import sys import sysconfig +import tarfile import urllib.request ROOT = Path(__file__).resolve().parents[1] @@ -33,6 +34,14 @@ APP_ID = 'org.flashml.FreeVideo' NAME = RELEASE_ASSETS[LINUX_CHANNEL][0] TOOLS = { + # stunnel/static-curl 8.22.0, x86_64 musl: fully static, HTTPS + HTTP/SOCKS + # proxies, with embedded Mozilla roots. The dev archive supplies the original + # licenses for curl AND its linked components; headers/libraries are not shipped. + 'curl': dict(version='8.22.0', + url='https://github.com/stunnel/static-curl/releases/download/8.22.0/curl-linux-x86_64-dev-8.22.0.tar.xz', + sha256='93a928c9bab36604ff646e7de31c9d4b789145acf8c7cda75e7da2e1221c5cc7', + binary_sha256='b7443db66d622a27ef2152bb2032d6ada9fb29a8917f22b9521a993f8e61cd7f', + ca_sha256='f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9'), 'appimagetool': dict(url='https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage', sha256='ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0'), 'runtime': dict(url='https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64', @@ -90,11 +99,43 @@ def tool(cache, name): stage.replace(path) if hashlib.sha256(path.read_bytes()).hexdigest() != spec['sha256']: raise SystemExit('Pinned %s does not match its checksum: %s' % (name, path)) - if not name.startswith('font'): + if name in ('appimagetool', 'runtime'): path.chmod(0o755) return path +def bundle_curl(cache, destination, notices): + """Stage only the verified static executable and its original license texts.""" + spec = TOOLS['curl'] + destination.mkdir(parents=True, exist_ok=True) + licenses = notices / 'curl' + licenses.mkdir(parents=True, exist_ok=True) + with tarfile.open(tool(cache, 'curl'), 'r:xz') as archive: + # Read exact members, never extract archive paths or follow archive links. + member = archive.getmember('curl-x86_64/bin/curl') + if not member.isfile(): + raise ValueError('Bundled curl must be a regular file') + data = archive.extractfile(member).read() + if hashlib.sha256(data).hexdigest() != spec['binary_sha256']: + raise ValueError('Bundled curl binary does not match its checksum') + executable = destination / 'curl' + executable.write_bytes(data) + executable.chmod(0o755) + copied = [] + for member in archive.getmembers(): + path = Path(member.name) + if path.parent == Path('curl-x86_64/licenses') and member.isfile(): + (licenses / (path.name + '.txt')).write_bytes(archive.extractfile(member).read()) + copied.append(path.name) + if not {'curl', 'ca-bundle', 'openssl'} <= set(copied): + raise ValueError('Bundled curl licenses are missing') + (licenses / 'SOURCE.txt').write_text( + 'Static curl %s (Linux x86_64 musl)\n%s\nArchive SHA256: %s\nBinary SHA256: %s\n' + 'Build sources: https://github.com/stunnel/static-curl/tree/%s\n' % + (spec['version'], spec['url'], spec['sha256'], spec['binary_sha256'], spec['version']), encoding='utf-8') + return executable + + def launcher_characters(root): text = '' for path in [*(root / 'freevideo_engine').rglob('*.py'), *(root / 'freevideo_engine').rglob('*.qml'), @@ -206,6 +247,7 @@ def main(): stamp = stamp_source(staged, build) notices = staged / 'freevideo_engine/launcher/licenses/bundled' collect_notices(ROOT, notices) + curl = bundle_curl(tools, out / 'curl', notices) fonts = out / 'fonts' glyphs = make_fonts(ROOT, tools, fonts) shutil.copyfile(fonts / 'OFL.txt', notices / 'Noto-Sans-CJK-SC-OFL.txt') @@ -227,6 +269,7 @@ def main(): from scripts.source_build import run_pyinstaller # These files are added after PyInstaller; they belong to the same base key. extra_runtime = {'appimage-runtime': str(runtime), 'appimagetool': str(appimagetool)} + extra_runtime['curl'] = str(curl) for name in SYSTEM_LIBRARIES: path = system_library(name, args.libraries.resolve() if args.libraries else None) if path is not None: @@ -235,6 +278,9 @@ def main(): 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) + # Copy after PyInstaller: a static ELF must not be rewritten or analyzed as + # a shared library. The base manifest above still binds its exact bytes. + shutil.copytree(curl.parent, dist / '_internal/curl') 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)) diff --git a/web/error_panel.js b/web/error_panel.js index 77124c8d..e1d8a333 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -229,7 +229,8 @@ function reportRedactor(detail, context) { .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '
'); // Keep traceback source basenames and line numbers, never drive names, // home directories or private media filenames. Spaces in paths count. - text = text.replace(/(^|[^A-Za-z0-9_:/<>])((?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/]|\/(?![\/\s]))[^\r\n"'<>;,\)\]]+)/gm, (_, prefix, path) => { + text = text.replace(/(^|[^A-Za-z0-9_:/<>])(\.\/setup\.sh(?![\w\/\\-]|\.\w)|\.{1,2}\/[^\r\n"'<>;,\)\]]*|(?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/]|\/(?![\/\s]))[^\r\n"'<>;,\)\]]+)/gm, (_, prefix, path) => { + if (path === './setup.sh') return prefix + path; const source = path.replace(/\\/g, '/').match(/\/([A-Za-z0-9_.-]+\.(?:py|js|ts|mjs))(?=[:\s]|$)/); return prefix + (source ? '/' + source[1] + path.slice(path.indexOf(source[1]) + source[1].length) : ''); }); From 8310c35ffc5485cddd6bbb1e12e8eb67f388383b Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 22 Oct 2026 23:54:08 +0000 Subject: [PATCH 18/18] Release 0.4.19 --- freevideo_engine/release_notes.json | 83 +++++++++++++++++++---------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 9ff162fa..dbfb4815 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,70 @@ { "schema": 1, - "product_version": "0.4.18", + "product_version": "0.4.19", "en": { - "summary": "FreeVideo can be installed into deeply nested folders on Windows; many failure messages state the cause and the next step; Windows computers with little free memory can complete generation; consecutive requests run faster on computers with ample memory.", + "summary": "Several pictures can be deleted from the image history at once; prompt enhancement states the reason when it cannot finish.", "changes": [ - "Install: FreeVideo can be installed into deeply nested folders on Windows; when an update cannot be installed and the previous version is restored, the notice follows the interface language.", - "Launcher: errors state the step that failed and what to do next, and Report a problem includes a line about the environment; when the saved installation folder cannot be opened, Start opens the setup page with the saved paths and an explanation; a graphics driver older than 580 gets a notice with the installed version and the update steps; a damaged launcher settings file is kept as a copy before it is replaced; the launcher keeps working when the prompt model file list is missing, and that row appears once the list is available.", - "Model downloads: slow sources are retried with a longer time limit, and the last failure reason of each source and route is listed; when a source has problems, the launcher shows a dedicated card with a button to switch sources; the image model has its own download list with links and checksums, exported to a separate folder.", - "Failure messages: a failed video generation names the stage where it stopped (loading, sampling or decoding); failure feedback starts with the last error; automatic subtitles, subtitle downloads, media import and subtitle import in the editor state the reason when they fail, and GBK-encoded SRT subtitles can be imported; image generation, adding workflows and share export state when the disk is full; an unreadable queued reference image prompts for a different image; a failed part of a long video shows a short status, with details in the error card and the name of any missing file.", - "Generation: fixed an error when submitting a generation; when first/last frames and references cannot be combined, the failure card says which media to turn off; Generate with AI explains when the media list is full at 32 items; the estimate before generation marks first-time preparation, and a slow estimate gives a reason and a remedy; a queued request shows its wait in minutes or the number of requests ahead; a queued export shows Queued on its button; LoRA files in more naming styles are recognized, and an acceleration LoRA made for the original model gets its own explanation.", - "Prompt enhancement: scenes with audio, connected nodes or videos can be enhanced, with audio and connected nodes skipped and one frame read from each video; with reference images, a rewrite that leaves out an image reference is sent back to the model once to add it.", - "Reference images: uploading and reading show Uploading reference image and Reading reference image N%; the + button in the editor shows a spinner while loading; library import shows uploading, queued and processing in turn, and finishes once progress reaches 100%.", - "Autopilot: it can be stopped while waiting for a reply and shows its current action; after three minutes without a progress update it says generation is still running; a background tab title starts with Confirmation needed, Failed or Done; Edit prompt puts the summary back in the input box; the confirmation card shows the size and duration; when the entry check fails, the entry stays and the check can be retried; an unreadable saved conversation or film leaves the rest available; failure reasons are readable in the English interface.", - "Multi-shot films: for long videos and multi-shot films made by Autopilot, music is added on request: when requested, one continuous score is laid over the whole film after the shots are joined, and a music file from the user can be used directly.", - "Create and Your creations: saving or loading a prompt can be retried after a failure; key presses stay inside the creation panel; in the image editor, the History entry stays visible after a picture is placed from the history; the end of the Your creations list shows how many records cannot be read.", - "Memory and speed: when a Windows computer is refused only because of the default memory reserve, part of the reserve is used, keeping at least 1 GiB, so generation can start with about 5 GiB free; on computers with ample memory, consecutive requests skip reloading the models and run faster.", - "Mac: encoding with many reference images is much faster; accelerated computation falls back to the standard method when its results look wrong; after a one-click update, a new version that starts slowly is kept." + "Install: setup no longer stops when some graphics card details cannot be read; command-line generation works on Windows when FreeVideo is installed in a very deep folder.", + "Image history: after Select, several pictures can be checked and deleted at once, with a second click to confirm; pictures that are in use or cannot be deleted stay in place with the reason shown.", + "Prompt enhancement: when enhancement cannot finish and the original prompt is used, the reason is shown; a rewrite that lacks the video or audio description is sent back to the model to add it.", + "Editor: Extend on a finished video without a description asks for a description first instead of showing an English validation error; when the description box is cleared, the description carried over for that segment is used.", + "The Linux package now includes curl, so installation and downloads work on systems without it.", + "Download list: the check no longer hashes every model file again each time it runs; files already hashed are skipped while their size, times and inode are unchanged.", + "Keeps the failed phase, step count, error type and memory readings of a GPU error that raised the compatibility level, so a diagnostic report sent after a later run still shows where it stopped.", + "The chat no longer shows the notice for an edited waiting message as an error with a report button; it is a plain status line." ] }, "zh": { - "summary": "Windows 上可以安装到层级较深的文件夹;多处失败提示写明原因和下一步操作;可用内存较少的 Windows 电脑可以继续完成生成;内存宽裕的电脑连续生成时速度更快。", + "summary": "生图历史可以一次删除多张;提示词增强未能完成时写明原因。", "changes": [ - "安装:Windows 上可以安装到层级较深的文件夹;更新未能装上并换回旧版时,提示按界面语言显示。", - "启动器:出错时写明出错的步骤和下一步操作,“报告问题”附带一行环境信息;已保存的安装文件夹无法打开时,点击“启动”会进入安装页,显示已保存的路径和说明;显卡驱动版本低于 580 时,显示当前驱动版本和更新步骤;启动设置文件损坏时,先保留一份副本再保存;提示词模型的文件清单缺失时,启动器照常工作,相应一行在清单可用后显示。", - "模型下载:下载源较慢时以更长的时限重试,并分别列出每个下载源和线路最近一次失败的原因;下载源出现问题时,启动器显示专门的卡片和切换下载源的按钮;图像模型有单独的下载清单,列出链接和校验值,并导出到单独的文件夹。", - "失败提示:视频生成失败时写明出错的阶段(载入、采样或解码);生成失败后的反馈以最后一条错误开头;剪辑页的自动字幕、字幕下载、素材导入和字幕导入失败时写明原因,GBK 编码的 SRT 字幕可以导入;图像生成、补写工作流和分享导出遇到磁盘已满时写明原因;排队中的参考图无法读取时,提示更换图片;长视频的某一段失败时显示简短状态,详细内容在错误卡片中,并写出缺失文件的名称。", - "生成:修复了部分情况下提交生成时报错的问题;首尾帧与参考素材不能同时使用时,失败卡片说明原因并提示关闭哪一类素材;素材列表已满 32 项时,“AI 生成”说明原因;生成前的时间估计注明“含首次准备”,预计较慢时给出一个原因和一个处理办法;排队时显示“约 N 分钟后开始”或“前面还有 N 个”;导出排队时按钮显示“排队中”;更多命名方式的 LoRA 文件可以正确识别,为原版模型制作的加速 LoRA 会显示专门说明。", - "提示词增强:场景中有音频、连接节点或视频时也可以增强,音频和连接节点会被跳过,视频读取一帧画面;带参考图时,改写结果遗漏图片引用,会请模型补上一次。", - "参考图:上传和读取时显示“正在上传参考图”和“正在读取参考图 N%”;剪辑页的“+”按钮在加载时显示转圈;素材库导入依次显示上传中、排队中和处理中,进度到 100% 后直接完成。", - "AI 全自动:等待回复时可以停止,并显示当前动作;三分钟没有进度更新时,提示仍在生成中;后台标签页的标题前显示“待确认”“失败”或“已完成”;“改描述”会把摘要放回输入框;确认卡片显示尺寸和时长;入口检查失败时保留入口,可以重试;某个已保存的对话或影片无法读取时,其余内容照常可用;英文界面下的失败原因清楚可读。", - "多镜头作品:AI 全自动制作长视频和多镜头作品时,配乐按需添加:需要配乐时,在各镜头拼接完成后为整片铺一段连贯的配乐,用户提供的音乐文件也可以直接使用。", - "创作面板与我的作品:提示词保存或读取失败时可以重试;按键操作只作用于创作面板;图像编辑中从历史记录放入画布后,“历史”入口保持可见;“我的作品”列表末尾显示无法读取的记录数量。", - "内存与速度:Windows 电脑只因默认预留空间不足而无法开始生成时,会使用部分预留空间(至少保留 1 GiB),可用内存约 5 GiB 时也可以开始生成;连续生成多个请求时,内存宽裕的电脑无需重复加载模型,速度更快。", - "Mac:使用大量参考图时,编码速度明显提升;加速计算的结果异常时,自动改用标准计算;一键更新后,启动较慢的新版本同样会保留。" + "安装:部分显卡信息读不到时,安装不再中断;Windows 上 FreeVideo 安装在很深的文件夹时,命令行生成可以正常运行。", + "生图历史:点“选择”后可以勾选多张一次删除,删除前需要再次确认;被占用或无法删除的图片会保留并写明原因。", + "提示词增强:增强未能完成、改用原提示词时,写明原因;改写结果缺少视频或音频描述时,会请模型补上。", + "剪辑页:在没有描述的成片后“接着生成”时,会提示先写一句描述,不再显示英文校验错误;描述框被清空时,沿用这一段原有的描述。", + "Linux 安装包自带 curl,系统未安装 curl 时也能正常安装和下载。", + "下载清单:检查不再每次重新校验所有模型文件;已经校验过的文件在大小、时间和 inode 都没变时直接跳过。", + "保留导致兼容档位升高的显卡错误的出错阶段、步数、错误类型和内存读数,之后一次运行发送的诊断报告仍能看出它停在哪里。", + "对话中修改了正在等待 Agent 的消息后,提示不再按错误显示、不再带“报告问题”按钮,改为普通状态提示。" ] }, "history": [ + { + "product_version": "0.4.18", + "en": { + "summary": "FreeVideo can be installed into deeply nested folders on Windows; many failure messages state the cause and the next step; Windows computers with little free memory can complete generation; consecutive requests run faster on computers with ample memory.", + "changes": [ + "Install: FreeVideo can be installed into deeply nested folders on Windows; when an update cannot be installed and the previous version is restored, the notice follows the interface language.", + "Launcher: errors state the step that failed and what to do next, and Report a problem includes a line about the environment; when the saved installation folder cannot be opened, Start opens the setup page with the saved paths and an explanation; a graphics driver older than 580 gets a notice with the installed version and the update steps; a damaged launcher settings file is kept as a copy before it is replaced; the launcher keeps working when the prompt model file list is missing, and that row appears once the list is available.", + "Model downloads: slow sources are retried with a longer time limit, and the last failure reason of each source and route is listed; when a source has problems, the launcher shows a dedicated card with a button to switch sources; the image model has its own download list with links and checksums, exported to a separate folder.", + "Failure messages: a failed video generation names the stage where it stopped (loading, sampling or decoding); failure feedback starts with the last error; automatic subtitles, subtitle downloads, media import and subtitle import in the editor state the reason when they fail, and GBK-encoded SRT subtitles can be imported; image generation, adding workflows and share export state when the disk is full; an unreadable queued reference image prompts for a different image; a failed part of a long video shows a short status, with details in the error card and the name of any missing file.", + "Generation: fixed an error when submitting a generation; when first/last frames and references cannot be combined, the failure card says which media to turn off; Generate with AI explains when the media list is full at 32 items; the estimate before generation marks first-time preparation, and a slow estimate gives a reason and a remedy; a queued request shows its wait in minutes or the number of requests ahead; a queued export shows Queued on its button; LoRA files in more naming styles are recognized, and an acceleration LoRA made for the original model gets its own explanation.", + "Prompt enhancement: scenes with audio, connected nodes or videos can be enhanced, with audio and connected nodes skipped and one frame read from each video; with reference images, a rewrite that leaves out an image reference is sent back to the model once to add it.", + "Reference images: uploading and reading show Uploading reference image and Reading reference image N%; the + button in the editor shows a spinner while loading; library import shows uploading, queued and processing in turn, and finishes once progress reaches 100%.", + "Autopilot: it can be stopped while waiting for a reply and shows its current action; after three minutes without a progress update it says generation is still running; a background tab title starts with Confirmation needed, Failed or Done; Edit prompt puts the summary back in the input box; the confirmation card shows the size and duration; when the entry check fails, the entry stays and the check can be retried; an unreadable saved conversation or film leaves the rest available; failure reasons are readable in the English interface.", + "Multi-shot films: for long videos and multi-shot films made by Autopilot, music is added on request: when requested, one continuous score is laid over the whole film after the shots are joined, and a music file from the user can be used directly.", + "Create and Your creations: saving or loading a prompt can be retried after a failure; key presses stay inside the creation panel; in the image editor, the History entry stays visible after a picture is placed from the history; the end of the Your creations list shows how many records cannot be read.", + "Memory and speed: when a Windows computer is refused only because of the default memory reserve, part of the reserve is used, keeping at least 1 GiB, so generation can start with about 5 GiB free; on computers with ample memory, consecutive requests skip reloading the models and run faster.", + "Mac: encoding with many reference images is much faster; accelerated computation falls back to the standard method when its results look wrong; after a one-click update, a new version that starts slowly is kept." + ] + }, + "zh": { + "summary": "Windows 上可以安装到层级较深的文件夹;多处失败提示写明原因和下一步操作;可用内存较少的 Windows 电脑可以继续完成生成;内存宽裕的电脑连续生成时速度更快。", + "changes": [ + "安装:Windows 上可以安装到层级较深的文件夹;更新未能装上并换回旧版时,提示按界面语言显示。", + "启动器:出错时写明出错的步骤和下一步操作,“报告问题”附带一行环境信息;已保存的安装文件夹无法打开时,点击“启动”会进入安装页,显示已保存的路径和说明;显卡驱动版本低于 580 时,显示当前驱动版本和更新步骤;启动设置文件损坏时,先保留一份副本再保存;提示词模型的文件清单缺失时,启动器照常工作,相应一行在清单可用后显示。", + "模型下载:下载源较慢时以更长的时限重试,并分别列出每个下载源和线路最近一次失败的原因;下载源出现问题时,启动器显示专门的卡片和切换下载源的按钮;图像模型有单独的下载清单,列出链接和校验值,并导出到单独的文件夹。", + "失败提示:视频生成失败时写明出错的阶段(载入、采样或解码);生成失败后的反馈以最后一条错误开头;剪辑页的自动字幕、字幕下载、素材导入和字幕导入失败时写明原因,GBK 编码的 SRT 字幕可以导入;图像生成、补写工作流和分享导出遇到磁盘已满时写明原因;排队中的参考图无法读取时,提示更换图片;长视频的某一段失败时显示简短状态,详细内容在错误卡片中,并写出缺失文件的名称。", + "生成:修复了部分情况下提交生成时报错的问题;首尾帧与参考素材不能同时使用时,失败卡片说明原因并提示关闭哪一类素材;素材列表已满 32 项时,“AI 生成”说明原因;生成前的时间估计注明“含首次准备”,预计较慢时给出一个原因和一个处理办法;排队时显示“约 N 分钟后开始”或“前面还有 N 个”;导出排队时按钮显示“排队中”;更多命名方式的 LoRA 文件可以正确识别,为原版模型制作的加速 LoRA 会显示专门说明。", + "提示词增强:场景中有音频、连接节点或视频时也可以增强,音频和连接节点会被跳过,视频读取一帧画面;带参考图时,改写结果遗漏图片引用,会请模型补上一次。", + "参考图:上传和读取时显示“正在上传参考图”和“正在读取参考图 N%”;剪辑页的“+”按钮在加载时显示转圈;素材库导入依次显示上传中、排队中和处理中,进度到 100% 后直接完成。", + "AI 全自动:等待回复时可以停止,并显示当前动作;三分钟没有进度更新时,提示仍在生成中;后台标签页的标题前显示“待确认”“失败”或“已完成”;“改描述”会把摘要放回输入框;确认卡片显示尺寸和时长;入口检查失败时保留入口,可以重试;某个已保存的对话或影片无法读取时,其余内容照常可用;英文界面下的失败原因清楚可读。", + "多镜头作品:AI 全自动制作长视频和多镜头作品时,配乐按需添加:需要配乐时,在各镜头拼接完成后为整片铺一段连贯的配乐,用户提供的音乐文件也可以直接使用。", + "创作面板与我的作品:提示词保存或读取失败时可以重试;按键操作只作用于创作面板;图像编辑中从历史记录放入画布后,“历史”入口保持可见;“我的作品”列表末尾显示无法读取的记录数量。", + "内存与速度:Windows 电脑只因默认预留空间不足而无法开始生成时,会使用部分预留空间(至少保留 1 GiB),可用内存约 5 GiB 时也可以开始生成;连续生成多个请求时,内存宽裕的电脑无需重复加载模型,速度更快。", + "Mac:使用大量参考图时,编码速度明显提升;加速计算的结果异常时,自动改用标准计算;一键更新后,启动较慢的新版本同样会保留。" + ] + } + }, { "product_version": "0.4.17", "en": {