From 24ba05e469f2d0075330858491cfc61ba9a0a707 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 14:59:45 +0000 Subject: [PATCH 01/43] Release 0.5.2: update notes --- freevideo_engine/bootstrap.py | 10 +++++- freevideo_engine/policy.py | 33 ++++++++++++++++++-- web/editor_export_dialog.css | 6 ++++ web/editor_export_dialog.js | 18 +++++++++-- web/generation_progress.css | 5 +++ web/generation_progress.js | 58 +++++++++++++++++++++++++++++------ web/setup.css | 6 +++- web/studio.css | 25 +++++++++------ web/studio.js | 15 +++++---- 9 files changed, 143 insertions(+), 33 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index a555f84f..0ab48a33 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -619,17 +619,25 @@ def plan(args, *, local_progress=None, stage=None): model_dir = Path(args.models or saved.get('model_root') or root / 'models' / 'vdn').expanduser().resolve() encoder_dir = Path(args.encoder_models or saved.get('encoder_model_root') or prior.get('encoder_dir') or root / 'models' / 'encoder').expanduser().resolve() reuse_cache = args.cache.expanduser().resolve() if args.cache else None + own_model = False if reuse_cache is None and not getattr(args, 'rebuild_cache', False): revision = json.loads((PACKAGE / 'dependencies.json').read_text(encoding='utf-8'))['models']['vdn_revision'] + from . import prepared_model + own = prepared_model.select(root=root, **cache_format) if getattr(args, 'model_source', 'prepared') == 'prepared' else None for name in ('machine.json', 'prepared-cache.json'): cache_record = root / name if cache_record.is_file(): record = json.loads(cache_record.read_text(encoding='utf-8')) candidate = record.get('cache') if record.get('model_revision') == revision and candidate and cache_compatible(candidate, **cache_format): + if own and Path(candidate).resolve() == Path(own['cache']).resolve(): + # The installation's own downloaded model: plan it like a fresh + # download, so a missing or damaged file is listed and fetched again. + own_model = True + break reuse_cache = Path(candidate) break - if reuse_cache is None and not getattr(args, 'rebuild_cache', False): + if reuse_cache is None and not own_model and not getattr(args, 'rebuild_cache', False): from .install_tuning import discover_prepared reuse_cache = discover_prepared(getattr(args, 'reuse_models', None), **cache_format) from . import prepared_model diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 72715719..77abf85c 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -951,6 +951,16 @@ def choose_placement(hardware: Hardware, *, canvas=None, stage='generation', **o capped = _choose_with_step_cache(hardware, canvas=canvas, stage=stage, commit_cap=cap, **options) except ResourceBudgetError: capped = None + if capped is not None and shape(capped) != shape(base) and capped.engine.get('prefetch'): + # The slot's block can be what moves a compute shape at this cap: + # it gives way to the weights here rather than raising the cap. + try: + unslotted = _choose_with_step_cache(hardware, canvas=canvas, stage=stage, commit_cap=cap, + slot_from_cap=True, **options) + except ResourceBudgetError: + unslotted = None + if unslotted is not None and shape(unslotted) == shape(base): + capped = unslotted if capped is not None and shape(capped) == shape(base): # The decoder runs after sampling has released the transformer and # keeps the request's own budget, prefetch slot included. @@ -1021,6 +1031,7 @@ def choose(hardware: Hardware, *, canvas=None, stage='generation', **options): plain = {k: v for k, v in canvas.items() if k != 'step_cache'} # The plan's own commit cap is charged too, or its engine could never match. charged = _choose(hardware, canvas=plain, stage=stage, commit_cap=plan.commit_gpu_limit_bytes, + slot_from_cap=plan.commit_gpu_limit_bytes is not None and not plan.engine.get('prefetch'), **{**options, 'step_cache_bytes': extra}) except ResourceBudgetError: return plan @@ -1035,10 +1046,12 @@ def _choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto' gpu_reserve_gib=None, ram_reserve_gib=None, available_backends=None, canvas=None, demonstrated_ram_bytes=None, allow_capacity_trial=False, stage='generation', lora_max_block_bytes=0, lora_root_bytes=0, precision='fp8', step_cache_bytes=0, - commit_cap=None): + commit_cap=None, slot_from_cap=False): """precision is the prepared cache's: int8 caches run int8 projections. - commit_cap is the sampling budget the Windows commit headroom allows, or None.""" + commit_cap is the sampling budget the Windows commit headroom allows, or None. + slot_from_cap judges the second transfer slot against that capped budget, as + a plan without the slot's own allowance would.""" from .geometry import geometry if stage not in ('encoding', 'generation'): raise ValueError('Resource planning stage must be encoding or generation') @@ -1240,7 +1253,21 @@ def _choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto' # which made residency drop as the budget grew: three blocks at 10.1 GiB # against one at 10.3 GiB. twelve = not small and gpu_budget < 14 * GiB - prefetch = prefetch_for_budget(gpu_budget, twelve=twelve, ampere=ampere, system=hardware.system) + # The second transfer slot is judged against what the card itself can hold. + # A commit cap lowers what sampling may reserve, and the slot is one block + # of that allowance: the checks below still give it up when the capped + # budget cannot hold it with the workspace, and residency, which buys no + # time, pays for it. Judging it against the capped budget instead removed + # the slot from every 16 GiB card whose commit headroom put the cap under + # 13.5 GiB, and each streamed layer was then copied between two layers' + # compute. The small band sizes its resident blocks from the whole budget + # and takes no allowance for the slot, so there the capped budget decides, + # as it always has; slot_from_cap asks for the same where giving the slot + # up is all that lets the cap keep the plan's compute shapes. + slot_budget = (gpu_budget if commit_limit is None or small or slot_from_cap + else request_gpu_budget - placement_workspace) + prefetch = prefetch_for_budget(slot_budget, twelve=not small and slot_budget < 14 * GiB, ampere=ampere, + system=hardware.system) # The larger head group is worth about 10% at no extra peak at all. Paying # for its wider activation allowance out of residency, measured on an H200 # at 1344x768x243 with the card as the ceiling: diff --git a/web/editor_export_dialog.css b/web/editor_export_dialog.css index a4d50f54..2e732394 100644 --- a/web/editor_export_dialog.css +++ b/web/editor_export_dialog.css @@ -9,6 +9,12 @@ .fv-ed-export-dialog .fv-ed-export-segments button{flex:1;min-width:0;height:auto;min-height:28px;padding:4px 5px;border:0;border-radius:7px;background:transparent;color:var(--fv-muted);font-size:12px;line-height:1.4;white-space:normal;overflow-wrap:normal} .fv-ed-export-dialog .fv-ed-export-segments button[aria-pressed=true]{background:var(--fv-raised);color:var(--fv-ink);font-weight:600} .fv-ed-export-dialog .fv-ed-export-segments button:hover:not(:disabled){background:var(--fv-hover)} +/* Choices share a row equally; a row whose words do not fit that way gives each choice its text width and shares the rest. + The hidden bold copy keeps a choice's width when it is picked. */ +.fv-ed-export-segments[data-fit] button{display:grid;place-content:center} +.fv-ed-export-segments[data-fit] button::after{content:attr(data-label);display:block;height:0;overflow:hidden;visibility:hidden;font-weight:600} +.fv-ed-export-dialog .fv-ed-export-segments[data-fit=measure] button{flex:none;white-space:nowrap} +.fv-ed-export-dialog .fv-ed-export-segments[data-fit=text] button{flex:1 1 auto} .fv-ed-export-notices{border-top:1px solid var(--fv-border);padding-top:20px;margin-top:24px;font-size:12px} .fv-ed-export-notices p{margin:0} .fv-ed-export-crop{color:var(--fv-warning)} diff --git a/web/editor_export_dialog.js b/web/editor_export_dialog.js index 4e31ebf5..e93f2972 100644 --- a/web/editor_export_dialog.js +++ b/web/editor_export_dialog.js @@ -104,7 +104,7 @@ export async function openExportDialog({parent, state, sources, t = en => en, on controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', label); controls.dataset.option = name; for (const [choice, text] of choices) { const node = button(text, () => { value[name] = choice; update(); }); - node.dataset.value = String(choice ?? 'same'); controls.append(node); + node.dataset.value = String(choice ?? 'same'); node.dataset.label = text; controls.append(node); } row.append(title, controls); host.append(row); groups.push({name, row, controls, choices}); return row; @@ -154,6 +154,7 @@ export async function openExportDialog({parent, state, sources, t = en => en, on pending.hidden = !seconds; pending.textContent = t(`${seconds} s still generating; that part exports as black.`, `有 ${seconds} 秒还在生成,导出后这部分是黑屏`); links.hidden = sound || !n; notices.hidden = links.hidden && pending.hidden; + if (dialog.open) fitSegments(); fit.disabled = parts.some(clip => ['queued', 'running'].includes(clip.pending)); const size = exportSizes(state.record).find(size => size.short === value.short) || state.record; // Same rounding as the player's total, so one edit never reads 0:31 there and 0:32 here. @@ -193,6 +194,19 @@ export async function openExportDialog({parent, state, sources, t = en => en, on dialog.addEventListener('mousedown', stop); dialog.addEventListener('mouseup', stop); dialog.addEventListener('click', stop); dialog.addEventListener('dblclick', stop); dialog.addEventListener('wheel', stop); dialog.addEventListener('contextmenu', stop); - parent.append(dialog); update(); dialog.showModal(); confirm.focus(); + // Equal shares unless a choice's words, bold when picked, would not fit on one line. + const fitSegments = () => { + for (const {controls} of groups) { + // A hidden row has no width yet; it is measured again when update() shows it. + if (!controls.clientWidth) { delete controls.dataset.fit; continue; } + controls.dataset.fit = 'measure'; + const nodes = [...controls.children], css = getComputedStyle(controls); + const room = controls.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight) - parseFloat(css.columnGap || 0) * (nodes.length - 1); + const tight = nodes.some(node => node.offsetWidth > room / nodes.length + .5); + if (tight) controls.dataset.fit = 'text'; else delete controls.dataset.fit; + } + }; + addEventListener('resize', fitSegments); cleanups.push(() => removeEventListener('resize', fitSegments)); + parent.append(dialog); update(); dialog.showModal(); fitSegments(); confirm.focus(); return result; } diff --git a/web/generation_progress.css b/web/generation_progress.css index e5a83f1f..4173e929 100644 --- a/web/generation_progress.css +++ b/web/generation_progress.css @@ -3,6 +3,11 @@ .fv-generation-heading{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:13px} .fv-generation-heading[hidden]{display:none!important} .fv-generation-label{display:block;margin-top:10px;font-size:13px;color:var(--fv-ink,#edf2f9);text-wrap:balance} +/* A stage sentence with its numbers: each number stays whole; when the line does not fit, the numbers go to the + next line together and the separator at the break is not shown (it stays in the text). */ +.fv-generation-label-part,.fv-generation-label-sep{white-space:nowrap} +.fv-generation-label-sep[data-cut]{font-size:0} +.fv-generation-label-rest[data-cut]{display:inline-block;width:100%} .fv-generation-count{font-size:clamp(26px,3vw,40px);font-weight:var(--fv-medium);line-height:1.2;letter-spacing:.02em;font-variant-numeric:tabular-nums;color:var(--fv-ink,#edf2f9)} .fv-generation-track{position:relative;height:4px;background:var(--fv-raised,#212a37);border-radius:999px;overflow:hidden} .fv-generation-fill{position:relative;height:100%;border-radius:inherit;overflow:hidden;background:linear-gradient(90deg,var(--fv-accent,#6db8fa),var(--fv-accent-ramp,#82dcd3));transition:width .6s var(--fv-ease-out,cubic-bezier(.22,1,.36,1));will-change:width} diff --git a/web/generation_progress.js b/web/generation_progress.js index cae94999..3aadec0d 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -170,7 +170,7 @@ function activityLabel(message, t, plan = {}, secondPass = false, references = f const [en, zh] = COUNT_UNITS[message.unit]; detail = t(`${done} / ${message.total} ${en}`, `${done} / ${message.total} ${zh}`); } else return result; - return {...result, text: `${result.text} · ${detail}`}; + return {...result, text: `${result.text} · ${detail}`, parts: [...result.parts || [result.text], detail]}; } function baseActivityLabel(message, t, plan = {}, secondPass = false, references = false) { const phase = message.phase || message.timing_phase || message.stage; @@ -178,17 +178,19 @@ function baseActivityLabel(message, t, plan = {}, secondPass = false, references if (phase === 'dependencies' && message.stage === 'probe') return named('prepare'); const download = DOWNLOADS[message.stage] || (phase === 'dependencies' ? DOWNLOADS.dependencies : null); if (download) { - let detail = ''; + const detail = []; if (valid(message.total) && message.total > 0) { const unit = message.total >= 2**30 ? 'GiB' : 'MiB'; const done = valid(message.done) ? Math.min(message.done, message.total) : 0; - detail = ` · ${size(done, unit)} / ${size(message.total, unit)} ${unit}`; + detail.push(`${size(done, unit)} / ${size(message.total, unit)} ${unit}`); if (valid(message.bytes_per_second) && message.bytes_per_second > 0) { const speed = message.bytes_per_second / 2**20; - detail += ` · ${speed >= 10 ? Math.round(speed) : speed.toFixed(1)} MiB/s`; + detail.push(`${speed >= 10 ? Math.round(speed) : speed.toFixed(1)} MiB/s`); } } - return {key: 'download', text: t(download[0], download[1]) + detail}; + // The sentence, then its size and speed: the parts the stage line keeps whole (see renderLabel). + const parts = [t(download[0], download[1]), ...detail]; + return {key: 'download', text: parts.join(' · '), parts}; } const steps = (key, name, step, total) => { const [en, zh, enSteps, zhSteps] = SAMPLING_NAME[name]; @@ -792,7 +794,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f // coming back within 2 s, whatever came between was stray messages (e.g. the // sampling plan and the upscaler being prepared during encoding): the stage // resumes with its own clock and bar, and the stage line never shows the others. - let stageId = null, stageSince = 0, labelId = null, labelText = '', away = null; + let stageId = null, stageSince = 0, labelId = null, labelText = '', labelParts = null, away = null; // Small bar under the stage line. A counted stage shows its real share after // 5 s; a slow stage without counts sweeps after 10 s; a shown bar stays at // least 1 s, and a counted stage without progress for 15 s gets a moving light. @@ -829,7 +831,8 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f // The current stage has lasted 2 s: the stage line names it from now on. function settle(at) { if (stageId !== null && stageId !== labelId && at - stageSince >= 2000) { - labelId = stageId; labelText = lastActivity ? lastActivity.text : labelText; away = null; + labelId = stageId; away = null; + if (lastActivity) { labelText = lastActivity.text; labelParts = lastActivity.parts || null; } } } function enterStage(id, at) { @@ -845,7 +848,7 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f stageId = id; stageSince = at; miniFraction = null; miniShownAt = null; } function resetMini() { - stageId = null; away = null; labelId = null; labelText = ''; + stageId = null; away = null; labelId = null; labelText = ''; labelParts = null; miniFraction = null; miniShownAt = null; miniMode = null; miniShown = null; miniShimmer = false; holdUntil = 0; mini.hidden = true; mini.dataset.mode = ''; mini.dataset.shimmer = 'false'; miniFill.style.width = ''; } @@ -874,14 +877,49 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f if (!lastActivity) { labelId = null; return t(...ACTIVITY.prepare); } settle(now()); if (labelId === null) { labelId = stageId; away = null; } - if (labelId === stageId) labelText = lastActivity.text; + if (labelId === stageId) { labelText = lastActivity.text; labelParts = lastActivity.parts || null; } return labelText; } + // A sentence with its numbers: each part stays whole. When the line does not fit, the numbers after the + // sentence go to the next line as one unit; between parts a line breaks only where they do not fit, and + // the separator there is not shown. The text stays the same string for reports and assistive technology. + const labelWatch = typeof ResizeObserver === 'function' ? new ResizeObserver(() => fitLabel()) : null; + function renderLabel(text, parts) { + if (!parts || parts.length < 2 || parts.join(' · ') !== text) { label.textContent = text; return; } + const line = el('span', 'fv-generation-label-text'); + let holder = line; + parts.forEach((part, index) => { + const piece = el('span', index ? 'fv-generation-label-part' : ''); + piece.textContent = part; holder.append(piece); + if (index === parts.length - 1) return; + const sep = el('span', 'fv-generation-label-sep'), rest = el('span', 'fv-generation-label-rest'); + sep.textContent = ' ·'; holder.append(sep, ' ', rest); holder = rest; + }); + label.replaceChildren(line); + fitLabel(); + } + function fitLabel() { + const rests = [...label.querySelectorAll('.fv-generation-label-rest')]; + if (!rests.length) return; + labelWatch?.observe(label); if (element.parentElement) labelWatch?.observe(element.parentElement); + for (const rest of rests) { delete rest.dataset.cut; delete rest.previousElementSibling.dataset.cut; } + const pieces = [label.firstElementChild.firstElementChild, ...rests.map(rest => rest.firstElementChild)]; + const rects = pieces.map(piece => [...piece.getClientRects()]); + if (!rects[0].length) return; // not shown; the observer lays it out once it is + const tops = rects.flat().map(rect => rect.top), half = rects[0][0].height / 2; + if (tops.every(top => Math.abs(top - tops[0]) < half)) return; + const cut = index => { rests[index].dataset.cut = 'true'; rests[index].previousElementSibling.dataset.cut = 'true'; }; + cut(0); + for (let index = 1; index < rests.length; index++) { + const before = [...pieces[index].getClientRects()].at(-1), after = pieces[index + 1].getClientRects()[0]; + if (before && after && after.top - before.top >= half) cut(index); + } + } function drawActivity(age, ended) { const paused = !ended && age > 15; const base = ended ? (overall.status === 'complete' || state.phase === 'complete' ? savedText() : t('Stopped', '已停止')) : paused ? detail?.text || stageText() : stageText(); - if (label.textContent !== base) label.textContent = base; + if (label.textContent !== base) renderLabel(base, base === labelText ? labelParts : null); drawMini(age, ended); const automatic = compatibility?.origin === 'automatic' && compatibility.level > 0; const compatibilityText = !ended && automatic ? compatibility.recovery_disabled diff --git a/web/setup.css b/web/setup.css index 92c65e94..2572942a 100644 --- a/web/setup.css +++ b/web/setup.css @@ -43,12 +43,16 @@ .fv-setup details.fv-adv>summary{padding-bottom:10px} .fv-setup details.fv-adv>details{margin:0;padding:0;border:0;border-top:1px solid var(--fv-border);border-radius:0;background:transparent} .fv-setup details.fv-adv>details>summary{padding:12px 0;font-weight:var(--fv-medium)} +/* A state that does not fit beside the title, and any failure, takes its own line below it; the chevron stays on the title's line. */ +.fv-setup details.fv-adv>details>summary{position:relative;flex-wrap:wrap;row-gap:2px;padding-right:23px} +.fv-setup details.fv-adv>details>summary::after{position:absolute;right:0;top:calc(12px + .5lh - 3.75px)} +.fv-setup details.fv-adv>details>summary>span.fv-failure{flex-basis:100%} .fv-setup details.fv-adv .fv-unit{display:flex;align-items:center;gap:8px;margin-top:6px} .fv-setup details.fv-adv .fv-unit>input.fv-number{width:96px;margin:0} .fv-setup details.fv-adv .fv-unit>span{white-space:nowrap} .fv-setup details.fv-adv>details[open]>summary{margin-bottom:0} .fv-setup details.fv-adv>details[open]{padding-bottom:12px} -.fv-setup details.fv-adv summary>span{margin-left:auto;font-weight:var(--fv-regular,400);transition:opacity .4s} +.fv-setup details.fv-adv summary>span{font-weight:var(--fv-regular,400);transition:opacity .4s} .fv-setup details.fv-adv summary>span.fv-fade{opacity:0} .fv-setup details.fv-adv>button.fv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;margin:0;padding:12px 0;min-height:0;border:0;border-top:1px solid var(--fv-border);border-radius:0;background:transparent;color:inherit;font:inherit;font-size:var(--fv-body);font-weight:var(--fv-medium);text-align:left;cursor:pointer} .fv-setup details.fv-adv>button.fv-row[hidden]{display:none} diff --git a/web/studio.css b/web/studio.css index e8e2695e..b2063872 100644 --- a/web/studio.css +++ b/web/studio.css @@ -14,9 +14,9 @@ .fv-studio .fv-header{height:76px;display:grid;grid-template-columns:1fr auto;align-items:center;padding:0 28px;border-bottom:1px solid var(--fv-border);gap:12px} .fv-brand{display:flex;min-width:0;align-items:center;gap:10px;font-size:var(--fv-strong);letter-spacing:-.5px;font-weight:var(--fv-semibold)} .fv-studio .fv-title{text-align:center}.fv-studio .fv-title strong{font-size:var(--fv-body);font-weight:var(--fv-medium)}.fv-studio .fv-header-actions{display:flex;justify-content:flex-end;align-items:center;gap:5px} -.fv-studio:not(.fv-editor)[open]{display:flex;flex-direction:column}.fv-studio .fv-header{flex:none} +.fv-studio:not(.fv-editor,.fv-autopilot,.fv-share)[open]{display:flex;flex-direction:column}.fv-studio .fv-header{flex:none} .fv-studio .fv-body{display:grid;grid-template-columns:420px minmax(0,1fr);flex:1 1 auto;min-height:0} -@media(max-width:680px){.fv-studio:not(.fv-editor)[open]{display:block}} +@media(max-width:680px){.fv-studio:not(.fv-editor,.fv-autopilot,.fv-share)[open]{display:block}} .fv-studio .fv-controls{min-height:0;overflow:auto;padding:25px 26px 0;border-right:1px solid var(--fv-border);scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent} .fv-studio .fv-label{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;font-weight:var(--fv-semibold);color:var(--fv-ink);font-size:var(--fv-body)} .fv-studio .fv-muted{color:var(--fv-muted);font-size:var(--fv-micro);font-weight:var(--fv-regular)} @@ -140,14 +140,16 @@ .fv-studio .fv-queue-actions{display:flex;align-items:center;justify-content:center;gap:4px;flex-wrap:wrap;margin-top:8px} .fv-studio .fv-queue-actions button,.fv-studio .fv-queue-details>summary{padding:4px 8px;min-height:28px;font-size:var(--fv-micro);color:var(--fv-muted)} .fv-studio .fv-queue-actions{position:relative}.fv-studio .fv-queue-details{position:static}.fv-studio .fv-queue-details>summary{cursor:pointer;list-style:none} -.fv-studio .fv-queue-list{left:0;right:auto;bottom:calc(100% + var(--fv-h-lg) + 24px);width:360px;max-height:260px;overflow:auto;scrollbar-width:thin} -.fv-studio .fv-queue-item{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:var(--fv-micro);padding:4px 6px;border-radius:var(--fv-r-sm);transition:background .3s ease;white-space:nowrap} -.fv-studio .fv-queue-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} -.fv-studio .fv-queue-text span{min-width:0;overflow:hidden;text-overflow:ellipsis} +.fv-studio .fv-queue-list{left:0;right:auto;bottom:calc(100% + var(--fv-h-lg) + 24px);width:360px;max-width:100%;max-height:260px;overflow:auto;scrollbar-width:thin} +.fv-studio .fv-queue-item{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:var(--fv-micro);padding:4px 6px;border-radius:var(--fv-r-sm);transition:background .3s ease;white-space:nowrap;flex-wrap:wrap;row-gap:4px} +/* The size line is never cut: it sets the text column's narrowest width; the prompt line takes what is left. */ +.fv-studio .fv-queue-text{flex:1 1 0;display:flex;flex-direction:column;gap:1px} +.fv-studio .fv-queue-summary{min-width:0;overflow:hidden;text-overflow:ellipsis;contain:inline-size} +.fv-studio .fv-queue-buttons{display:flex;align-items:center;gap:8px;margin-left:auto;flex:none} .fv-studio .fv-queue-summary{color:var(--fv-ink)}.fv-studio .fv-queue-meta{color:var(--fv-muted)} .fv-studio .fv-queue-item.fv-queue-moved{background:var(--fv-selected)} .fv-studio .fv-queue-chip{flex:none;color:var(--fv-accent)} -.fv-studio .fv-queue-item button{flex:none} +.fv-studio .fv-queue-buttons button{flex:none} /* A running row's progress: the main bar's accent on the divider colour; no number (the total shows once, above). */ .fv-studio .fv-queue-bar{position:relative;width:100%;height:4px;margin-top:4px;border-radius:999px;background:var(--fv-border);overflow:hidden} .fv-studio .fv-queue-fill{height:100%;width:0;border-radius:inherit;background:linear-gradient(90deg,var(--fv-accent),var(--fv-accent-ramp));transition:width .6s var(--fv-ease-out)} @@ -266,9 +268,12 @@ .fv-lora-problem{grid-column:var(--lora-lead,2)/span 3;grid-row:4;display:flex;flex-direction:column;align-items:flex-start;gap:6px;font-size:var(--fv-micro)}.fv-lora-problem p{margin:0;color:var(--fv-danger)}.fv-lora-problem.fv-lora-partial{color:var(--fv-muted)}.fv-lora-problem[hidden]{display:none}.fv-lora-why summary{cursor:pointer;color:var(--fv-muted)}.fv-lora-why pre{margin:6px 0 0;white-space:pre-wrap;word-break:break-all;color:var(--fv-muted);font:inherit;font-family:ui-monospace,monospace}.fv-lora-row[data-unusable=true] select{color:var(--fv-muted)} .fv-lora-row[data-unusable=true]{cursor:default}.fv-lora-row[data-unusable=true] :is(input[type=number],input[type=checkbox]){opacity:.45}.fv-lora-row[data-unusable=true] select{opacity:1}.fv-lora-row[data-unusable=true] :is(.fv-lora-trigger,.fv-lora-range){display:none} -/* The queued count follows the part number; when the line is too narrow it wraps out of sight. */ -.fv-studio .fv-chain-position:has(.fv-chain-queued){display:flex;flex-wrap:wrap;column-gap:0;line-height:1.4;max-height:1.4em;overflow:hidden} -.fv-studio .fv-chain-position .fv-chain-queued{white-space:pre} +/* The queued count follows the part number; a line too narrow for both moves the count, whole, to a second line. + The dot sits just left of the count over a hidden copy that keeps its room, so at the start of a line it falls outside the clip. */ +.fv-studio .fv-chain-position:has(.fv-chain-queued){display:flex;flex-wrap:wrap;column-gap:0;line-height:1.4;overflow:hidden} +.fv-studio .fv-chain-position:has(.fv-chain-queued)>span:first-child::after{content:' \B7 ';white-space:pre;visibility:hidden} +.fv-studio .fv-chain-position .fv-chain-queued{white-space:pre;position:relative} +.fv-studio .fv-chain-position .fv-chain-sep{position:absolute;right:100%;white-space:pre} .fv-studio .fv-replace-person{margin-top:0;margin-bottom:12px} .fv-studio .fv-replace-person.fv-swap-blocked{cursor:not-allowed} .fv-studio .fv-swap-on>.fv-panel{display:none} diff --git a/web/studio.js b/web/studio.js index 6621735c..6829a29e 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1352,10 +1352,11 @@ export function openStudio(node, {extendFrom = null} = {}) { cleanup.push(() => cancelAnimationFrame(seamFrame)); const progress = createGenerationProgress(t, undefined, {compact: true, api}); stage.append(progress.element); const chainLine = el('div', '', 'fv-muted fv-chain-position'); chainLine.hidden = true; progress.element.prepend(chainLine); - // The queued count sits after the part number; a line too narrow for both keeps the part number only. + // The queued count sits after the part number; a line too narrow for both puts the count on its own line. const setChainLine = text => { - const main = el('span', text), more = chainController?.waiting; - chainLine.replaceChildren(main, ...(more ? [el('span', t(` · ${more} more queued`, ` · 另有 ${more} 条排队`), 'fv-chain-queued')] : [])); + const main = el('span', text), more = chainController?.waiting, queued = el('span', null, 'fv-chain-queued'); + if (more) queued.append(el('span', ' · ', 'fv-chain-sep'), t(`${more} more queued`, `另有 ${more} 条排队`)); + chainLine.replaceChildren(main, ...(more ? [queued] : [])); }; progress.updateReport({report_id: node.freevideoReportId}); cleanup.push(() => progress.dispose()); @@ -1844,7 +1845,7 @@ export function openStudio(node, {extendFrom = null} = {}) { if (!item || item.classList.contains('fv-queue-moved')) return; item.classList.add('fv-queue-moved'); const chip = el('span', t('Moved to front', '已排到最前'), 'fv-queue-chip'); - item.insertBefore(chip, item.querySelector('button')); + item.querySelector('.fv-queue-buttons').prepend(chip); queueList.scrollTop = 0; clearTimeout(movedTimer); movedTimer = setTimeout(clearMoved, 2800); } @@ -1905,6 +1906,8 @@ export function openStudio(node, {extendFrom = null} = {}) { remove.setAttribute('aria-label', executing ? t(`Cancel ${size} · ${row.inputs.seconds} s`, `取消 ${size} · ${row.inputs.seconds} 秒`) : t(`Remove queued video ${index + 1}`, `移除排队的第 ${index + 1} 个`)); item.append(description); + // The buttons move together: on a narrow list they drop below the text, aligned right. + const actions = el('div', null, 'fv-queue-buttons'); if (!executing && index > 0) { const first = button(t('Move to front', '排到最前'), async () => { first.disabled = remove.disabled = true; @@ -1913,9 +1916,9 @@ export function openStudio(node, {extendFrom = null} = {}) { catch (error) { movedId = null; showQueueError(error); first.disabled = remove.disabled = false; } }, 'fv-quiet'); first.setAttribute('aria-label', t(`Move queued video ${index + 1} to front`, `把排队的第 ${index + 1} 个排到最前`)); - item.append(first); + actions.append(first); } - item.append(remove); queueList.append(item); + actions.append(remove); item.append(actions); queueList.append(item); }); } if (movedId && state.pending.some(row => row.id === movedId) && !queueList.querySelector('.fv-queue-moved')) showMoved(); From edf23833ec54a82cfd968088e558442e5d2fc12a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 15:23:44 +0000 Subject: [PATCH 02/43] Mac: record why a copied FreeVideo app fails verification, for the diagnostic report When the retained copy of FreeVideo.app behind the desktop shortcut does not verify, the launcher saves what failed (codesign exit and message, whether the running app itself verifies, stray ._ files, the volume type) to launcher/desktop-shortcut-failure.json, which the diagnostic report includes. The user-facing message and the verification itself are unchanged; the record is removed once a copy verifies. --- freevideo_engine/diagnostics.py | 3 + freevideo_engine/macos_shortcut.py | 90 ++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py index 1cac99ef..6c18f427 100644 --- a/freevideo_engine/diagnostics.py +++ b/freevideo_engine/diagnostics.py @@ -316,6 +316,9 @@ def add_path(name, path): shortcut = root / 'launcher/desktop-shortcut.json' if shortcut.is_file() and not is_link(shortcut): add_path('installation/desktop-shortcut.json', shortcut) + shortcut_failure = root / 'launcher/desktop-shortcut-failure.json' + if shortcut_failure.is_file() and not is_link(shortcut_failure): + add_path('installation/desktop-shortcut-failure.json', shortcut_failure) for label, directory in [('setup', setup), ('run', selected), ('optimization', optimized)]: if directory: for path in report_files(directory): diff --git a/freevideo_engine/macos_shortcut.py b/freevideo_engine/macos_shortcut.py index c5cd6d8e..ac27ef81 100644 --- a/freevideo_engine/macos_shortcut.py +++ b/freevideo_engine/macos_shortcut.py @@ -12,6 +12,9 @@ from .desktop_shortcut import digest, read_build from .monitoring import save +# Written when the retained copy cannot be verified; the diagnostic report includes it. +FAILURE = 'launcher/desktop-shortcut-failure.json' + def desktop_directory(): from PySide6.QtCore import QStandardPaths @@ -36,6 +39,73 @@ def application(): return app, executable +def _mount_type(path): + """File system type of the volume holding `path`, e.g. 'apfs' or 'exfat'; '' when unknown.""" + try: + out = getattr(subprocess.run(['/sbin/mount'], capture_output=True, timeout=10), 'stdout', b'') or b'' + text = out.decode('utf-8', 'replace') if isinstance(out, bytes) else str(out) + except (OSError, subprocess.SubprocessError): + return '' + best = ('', '') + for line in text.splitlines(): + before, _, rest = line.partition(' on ') + # The options sit at the end of the line; a volume name may itself contain ' ('. + point, _, tail = rest.rpartition(' (') + if point and (path == point or path.startswith(point.rstrip('/') + '/')) and len(point) >= len(best[0]): + best = (point, tail.split(',')[0].strip()) + return best[1] + + +def _codesign(path): + """(returncode, stderr text) of a strict deep verification, or (None, reason) when it did not run.""" + try: + result = subprocess.run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(path)], + capture_output=True, timeout=30) + except subprocess.TimeoutExpired: + return None, 'codesign timed out' + except (OSError, subprocess.SubprocessError) as error: + return None, 'codesign could not run: ' + str(error)[:200] + stderr = getattr(result, 'stderr', b'') or b'' + if isinstance(stderr, bytes): + stderr = stderr.decode('utf-8', 'replace') + return result.returncode, str(stderr).strip()[:1000] + + +def check_application(path, executable): + """None when the bundle at `path` is a verified copy of `executable`, else what failed.""" + if not path.is_dir() or path.is_symlink(): + return dict(reason='not a bundle folder') + code, message = _codesign(path) + if code is None: + return dict(reason=message) + if code != 0: + return dict(reason='codesign verification failed', codesign_exit=code, codesign_message=message) + try: + if digest(path / 'Contents/MacOS' / executable.name) != digest(executable): + return dict(reason='executable differs from the running one') + except OSError as error: + return dict(reason='executable could not be read: ' + str(error)[:200]) + return None + + +def _failure_record(engine, source, copy, executable, problem): + """Where verification failed and what the copy sits on, saved for the diagnostic report.""" + record = dict(problem, source_app=str(source), copy_parent=str(copy.parent), + copy_volume=_mount_type(str(copy.parent))) + try: + record['detritus'] = sorted(str(p.relative_to(copy)) for p in copy.rglob('._*'))[:10] + except OSError: + pass + # A running app that does not verify itself points at the app, not at copying it. + code, message = _codesign(source) + record['source_codesign_exit'] = code + record['source_codesign_message'] = message + try: + save(Path(engine) / FAILURE, record) + except OSError: + pass + + def retained_application(engine): app, executable = application() parent = Path(engine).resolve() / 'launcher/application.noindex' @@ -47,16 +117,13 @@ def retained_application(engine): import hashlib identity = hashlib.sha256((digest(executable) + digest(app / 'Contents/Info.plist')).encode()).hexdigest() target = parent / identity / 'FreeVideo.app' - def valid(path): - if not path.is_dir() or path.is_symlink(): - return False + def clear(): try: - result = subprocess.run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(path)], - capture_output=True, timeout=30) - return result.returncode == 0 and digest(path / 'Contents/MacOS' / executable.name) == digest(executable) - except (OSError, subprocess.SubprocessError): - return False - if valid(target): + (Path(engine) / FAILURE).unlink() + except OSError: + pass + if check_application(target, executable) is None: + clear() return target parent.mkdir(parents=True, exist_ok=True) stage = Path(tempfile.mkdtemp(prefix='launcher-', dir=parent)) @@ -64,11 +131,14 @@ def valid(path): prepared = stage / 'FreeVideo.app' # Preserve framework links, permissions and signing resources. subprocess.run(['/usr/bin/ditto', str(app), str(prepared)], check=True, capture_output=True, timeout=120) - if not valid(prepared): + problem = check_application(prepared, executable) + if problem: + _failure_record(engine, app, prepared, executable, problem) raise OSError('The copied FreeVideo application failed verification') if target.parent.exists(): target = parent / (identity + '-' + uuid.uuid4().hex[:8]) / 'FreeVideo.app' stage.rename(target.parent) + clear() return target except subprocess.SubprocessError as error: raise OSError('Could not prepare the FreeVideo desktop shortcut') from error From e6ac90806502489bd2f8e11137432ec9616c4a4c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 15:31:08 +0000 Subject: [PATCH 03/43] Export dialog: open without a window resize hook or layout, so the editor Node tests run again --- web/editor_export_dialog.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/editor_export_dialog.js b/web/editor_export_dialog.js index e93f2972..4b538641 100644 --- a/web/editor_export_dialog.js +++ b/web/editor_export_dialog.js @@ -196,6 +196,7 @@ export async function openExportDialog({parent, state, sources, t = en => en, on dialog.addEventListener('wheel', stop); dialog.addEventListener('contextmenu', stop); // Equal shares unless a choice's words, bold when picked, would not fit on one line. const fitSegments = () => { + if (typeof globalThis.getComputedStyle !== 'function') return; // no layout to measure (Node tests) for (const {controls} of groups) { // A hidden row has no width yet; it is measured again when update() shows it. if (!controls.clientWidth) { delete controls.dataset.fit; continue; } @@ -206,7 +207,8 @@ export async function openExportDialog({parent, state, sources, t = en => en, on if (tight) controls.dataset.fit = 'text'; else delete controls.dataset.fit; } }; - addEventListener('resize', fitSegments); cleanups.push(() => removeEventListener('resize', fitSegments)); + // Through globalThis, so a page without a window (the Node tests) still opens the dialog. + globalThis.addEventListener?.('resize', fitSegments); cleanups.push(() => globalThis.removeEventListener?.('resize', fitSegments)); parent.append(dialog); update(); dialog.showModal(); fitSegments(); confirm.focus(); return result; } From 1604e8d4901812e0592d07308d3eea5859df18ef Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 15:49:17 +0000 Subject: [PATCH 04/43] Autopilot: a message that was not sent shows its reason on its own line, with Retry and Report below --- web/autopilot_chat.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 5b90c0ba..d17bcbb3 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -183,7 +183,9 @@ .fv-chat-delete-error p{margin:0 0 4px} .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} +/* The reason takes the message's width on a line of its own; Retry and Report follow on the next line. */ .fv-chat-me .fv-chat-error{flex:1;min-width:0} +.fv-chat-me>.fv-chat-actions:not(.fv-chat-edit-actions)>.fv-chat-error{flex-basis:100%} .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)} From 891b5fcf9afab128227f7243906ab0605df1188d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 15:50:21 +0000 Subject: [PATCH 05/43] Offline import: complete model files left short by an interrupted write Importing an offline package again finishes a model file whose copy stops early, in the package store and in the install. Only a shorter file whose bytes equal the start of the verified original is completed; any other differing file is never overwritten. A refused rename or interrupted copy removes the temporary file. --- freevideo_engine/offline_packages.py | 74 +++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/freevideo_engine/offline_packages.py b/freevideo_engine/offline_packages.py index 53d0b687..51687d86 100644 --- a/freevideo_engine/offline_packages.py +++ b/freevideo_engine/offline_packages.py @@ -273,6 +273,57 @@ def copy_progress(source, target, progress): progress(done, total) +def is_cut_short(path, reader, size): + """The file at `path` stops early: shorter than `size`, every byte the start of `reader`. + + That is what an interrupted write leaves. A file that differs in any other way + is someone's own and is never treated as cut short. + """ + try: + have = io_path(path).stat().st_size + if have >= size: + return False + with io_path(path).open('rb') as stored: + while True: + chunk = stored.read(4 * 2**20) + if not chunk: + return True + if reader.read(len(chunk)) != chunk: + return False + except OSError: + return False + + +def cut_short_of(target, source, have, size): + """True when `target` (of `have` bytes) is an unfinished copy of the verified `source`.""" + if have >= size: + return False + try: + if os.path.samefile(target, source): + return False + with io_path(source).open('rb') as reader: + return is_cut_short(target, reader, size) + except OSError: + return False + + +def complete_cut_short(source, target, progress=None): + """Give a cut-short copy its full content: a new file beside it, then renamed over it.""" + source, target = io_path(source), io_path(target) + partial = target.with_name(target.name + '.repairing') + partial.unlink(missing_ok=True) + try: + try: + os.link(source, partial) + except OSError: + copy_progress(source, partial, progress or (lambda *_: None)) + os.replace(partial, target) + except BaseException: + # A refused rename or an interrupted copy must not leave gigabytes beside the model. + partial.unlink(missing_ok=True) + raise + + def import_archive(path, destination, progress=lambda **kw: None, cancelled=lambda: False): """Extract once, checking CRC-32, size and inventory SHA-256 while streaming. @@ -296,17 +347,20 @@ def _import_archive(path, destination, progress, cancelled): total = sum(r['bytes'] for r in value['files']) found = listing(root) if root.is_dir() else {} present = {row['path']: found[row['path']] for row in value['files'] if row['path'] in found} - missing = sum(r['bytes'] for r in value['files'] if r['path'] not in present) + missing = sum(r['bytes'] for r in value['files'] if r['path'] not in present or present[r['path']][0] != r['bytes']) parent = root while not parent.exists(): parent = parent.parent if disk_space.free_bytes(parent) < missing + 64 * 2**20: raise ValueError('磁盘空间不足,导入此包还需约 %.1f GiB。' % (missing / 2**30)) known, verified = stamps(root), {} + with zipfile.ZipFile(path) as archive: + cut = {row['path'] for row in value['files'] if row['path'] in present and present[row['path']][0] < row['bytes'] + and is_cut_short(root / row['path'], archive.open(PREFIX + row['path']), row['bytes'])} for row in value['files']: current = present.get(row['path']) - if current is None: - continue + if current is None or row['path'] in cut: + continue # Absent, or cut short by an interrupted write: extracted again below. if current[0] != row['bytes'] or not unchanged(root / row['path'], row, known, current, progress=file_progress(progress, 'verify', row['bytes'])): raise ValueError('已有导入文件已改变,未覆盖:' + plain(root / row['path'])) @@ -468,7 +522,9 @@ def place(src, target): if row['path'] in here: if not os.path.samefile(target, src) and hash_file(target, progress=file_progress(progress, 'verify', row['bytes'])) != row['sha256']: - raise ValueError('离线安装已有不同模型,未覆盖:' + plain(target)) + if not cut_short_of(target, src, here[row['path']][0], row['bytes']): + raise ValueError('离线安装已有不同模型,未覆盖:' + plain(target)) + complete_cut_short(src, target, file_progress(progress, 'copy_bytes', row['bytes'])) else: place(src, target) included.append(row) @@ -480,13 +536,19 @@ def place(src, target): if not package.is_file(): continue for row in json.loads(package.read_text(encoding='utf-8')).get('files', []): - if row['path'] in listed or not row['path'].startswith(OPTIONAL_PREFIX) or row['path'] in here: + if row['path'] in listed or not row['path'].startswith(OPTIONAL_PREFIX): + continue + if row['path'] in here and here[row['path']][0] >= row['bytes']: continue current = walks[root].get(row['path']) if not current or not unchanged(io_path(root) / row['path'], row, known[root], current, progress=file_progress(progress, 'verify', row['bytes'])): continue # The engine fetches a table it cannot find when a request needs it. - place(io_path(root) / row['path'], runtime / row['path']) + if row['path'] in here: + if cut_short_of(runtime / row['path'], io_path(root) / row['path'], here[row['path']][0], row['bytes']): + complete_cut_short(io_path(root) / row['path'], runtime / row['path']) + else: + place(io_path(root) / row['path'], runtime / row['path']) listed.add(row['path']) progress(done=done, total=total, detail='检查配套包', activity='') # Only placed rows are recorded: a skipped Sampling caches table is fetched when needed. From 3c7ef4f6c7455b50f3881cef4aff60800d56f002 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 15:50:24 +0000 Subject: [PATCH 06/43] Failure advice: offline installations are told to import the offline package again The update status says whether the folder was installed from offline packages, and the repair advice for a model that is not fully installed names the Installation page of the launcher in that case. --- freevideo_engine/comfy_updates.py | 10 ++++++++++ web/repair_hint.js | 6 +++++- web/updates.js | 3 ++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/comfy_updates.py b/freevideo_engine/comfy_updates.py index c3d477b0..99991083 100644 --- a/freevideo_engine/comfy_updates.py +++ b/freevideo_engine/comfy_updates.py @@ -9,6 +9,7 @@ import time from . import launcher_update +from .paths import data_root from .release_notes import installed_details, public_details @@ -33,6 +34,14 @@ def installed_build(package): return None +def offline_installation(): + """Whether this folder was installed from offline packages (same test as bootstrap).""" + try: + return (data_root().parent / 'portable.json').is_file() + except OSError: + return False + + def launcher_view(status): """The parts of a launcher status a page may show; None without a launcher.""" if not isinstance(status, dict): @@ -84,6 +93,7 @@ def snapshot(self, *, client=False): state = dict(self.state) # Pages that reload themselves after an update polled recently. state['clients'] = int(now - self.client_seen < 20) + state['offline_installation'] = offline_installation() if launcher is not None: if launcher.get('channel') in launcher_update.CHANNELS: state['channel'] = launcher['channel'] diff --git a/web/repair_hint.js b/web/repair_hint.js index 49521780..5c6cd939 100644 --- a/web/repair_hint.js +++ b/web/repair_hint.js @@ -1,9 +1,13 @@ // Where to repair a damaged install depends on whether the launcher started this page. // U+2060 keeps "环境" and "安装/修复" from breaking across lines in narrow dialogs. let launcher = false; +let offlineInstall = false; export const setLauncherPresent = present => { launcher = Boolean(present); }; +export const setOfflineInstall = offline => { offlineInstall = offline === true; }; -export const repairAction = () => launcher +export const repairAction = () => offlineInstall + ? ['Import the offline package again on the Installation page of the launcher, then retry.', '可在启动器“安\u2060装”页重新导入离线包后重试。'] + : launcher ? ['Select "Repair" in the launcher under Settings > Environment, then retry.', '可在启动器“设置 → 环⁠境”里点“修复”后重试。'] : ['Select "Install / repair" in the page settings, then retry.', '可在页面设置中点“安⁠装⁠/⁠修⁠复”后重试。']; diff --git a/web/updates.js b/web/updates.js index 4c6c7974..086e4e84 100644 --- a/web/updates.js +++ b/web/updates.js @@ -1,5 +1,5 @@ import { api } from '../../scripts/api.js'; -import { setLauncherPresent } from './repair_hint.js'; +import { setLauncherPresent, setOfflineInstall } from './repair_hint.js'; import { issueButton } from './issue_button.js'; // Every platform downloads from one release: the latest vX.Y.Z. @@ -54,6 +54,7 @@ export async function checkUpdates() { } value = next; offline = false; setLauncherPresent(next.launcher); + setOfflineInstall(next.offline_installation); const phase = next.launcher?.phase || ''; // Switching to a faster model also restarts ComfyUI once. if (working.has(phase) || next.launcher?.model) updating = true; From 2eba98a4ffb6e58579c925d05a937bcf6fc6f0f9 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 16:00:47 +0000 Subject: [PATCH 07/43] Media panel: show the could-not-read line once, below all three rows The count the server returns covers every kind, so it now shows once under the characters, scenes and props rows instead of under each, and no longer calls every item a character. Also refreshes two out-of-date browser tests (Autopilot tabs and toolbar, the could-not-read lines). --- web/cast_library.css | 1 + web/cast_library.js | 11 +++++++---- web/freevideo.js | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/web/cast_library.css b/web/cast_library.css index e8047172..21ef78d1 100644 --- a/web/cast_library.css +++ b/web/cast_library.css @@ -60,3 +60,4 @@ .fv-studio .fv-panel .fv-cast-library .fv-cast-avatar,.fv-studio .fv-panel .fv-cast-library .fv-cast-add{padding:0;min-height:0} .fv-cast-libraries{display:grid;align-items:start;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 12px;min-width:0} .fv-cast-libraries>.fv-cast-library[hidden]{display:none} +.fv-cast-libraries>.fv-cast-unreadable{grid-column:1/-1;margin:-4px 0 10px} diff --git a/web/cast_library.js b/web/cast_library.js index fc3dd634..bffe649b 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -68,8 +68,10 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () }; function render() { avatars.replaceChildren(); - skipped.textContent = unreadable > 0 ? text(`${unreadable} ${unreadable === 1 ? 'character' : 'characters'} could not be read`, `另有 ${unreadable} 个角色无法读取`) : ''; - skipped.hidden = !unreadable; + // The count covers every kind (a file that cannot be read does not say what it was), so only the first row shows it. + const shown = kind === 'character' ? unreadable : 0; + skipped.textContent = shown > 0 ? text(`${shown} ${shown === 1 ? 'item' : 'items'} could not be read`, `另有 ${shown} 项无法读取`) : ''; + skipped.hidden = !shown; for (const row of rows) { const avatar = button('fv-cast-avatar', '', () => openDialog(row.id)); avatar.classList.toggle('fv-cast-mentioned', mentioned.has(row.id)); @@ -357,12 +359,13 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () window.addEventListener('focus', changed); window.addEventListener('freevideo-cast-changed', changed); render(); changed(); - return {element, list: () => rows, get, refresh, + // notice: the could-not-read line, which a page showing several libraries may move below all of them. + return {element, notice: skipped, list: () => rows, get, refresh, subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn); }, openNew: () => openDialog(), openEdit: id => openDialog(id), destroy() { if (disposed) return; - disposed = true; request++; current?.close(); subscribers.clear(); element.remove(); + disposed = true; request++; current?.close(); subscribers.clear(); element.remove(); skipped.remove(); window.removeEventListener('freevideo-cast-mentioned', mentions); window.removeEventListener('focus', changed); window.removeEventListener('freevideo-cast-changed', changed); diff --git a/web/freevideo.js b/web/freevideo.js index 033cf425..474d19d8 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -173,7 +173,7 @@ function mediaPanel(node, mount = null) { const cast = createCastLibrary({api, text, viewURL, uploadOne}); const places = createCastLibrary({api, text, viewURL, uploadOne, kind: 'setting'}); const props = createCastLibrary({api, text, viewURL, uploadOne, kind: 'prop'}); - const libraries = el('div', undefined, 'fv-cast-libraries'); libraries.append(cast.element, places.element, props.element); + const libraries = el('div', undefined, 'fv-cast-libraries'); libraries.append(cast.element, places.element, props.element, cast.notice); panel.prepend(libraries); const read = () => { const rows = JSON.parse(serialized.value || "[]"); From ae3265b951e9095633c0246651b6b83be3dd84b0 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 16:44:56 +0000 Subject: [PATCH 08/43] Nodes view: fit the graph below the floating view bar so it never covers node titles --- web/studio.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/studio.js b/web/studio.js index 6829a29e..23d08391 100644 --- a/web/studio.js +++ b/web/studio.js @@ -620,10 +620,15 @@ export function showNodes(node, dialog = null) { const left = Math.min(...nodes.map(n => n.pos[0])), top = Math.min(...nodes.map(n => n.pos[1] - 32)); const width = Math.max(...nodes.map(n => n.pos[0] + n.size[0])) - left; const height = Math.max(...nodes.map(n => n.pos[1] + n.size[1])) - top; - const scale = Math.min(1, (canvas.clientWidth - 96) / width, (canvas.clientHeight - 96) / height); + // The view bar floats over the top of the canvas; fit below it so it never covers the node title or outputs. + const box = canvas.getBoundingClientRect?.() || {top: 0, left: 0, right: canvas.clientWidth}, bar = document.querySelector('.fv-view-navigation:not([hidden])')?.getBoundingClientRect(); + const inset = bar && bar.height && bar.left < box.right && bar.right > box.left && bar.top < box.top + canvas.clientHeight / 2 + ? Math.max(0, Math.min(bar.bottom - box.top, canvas.clientHeight / 2)) : 0; + const room = canvas.clientHeight - inset; + const scale = Math.min(1, (canvas.clientWidth - 96) / width, (room - 96) / height); if (scale > 0) { app.canvas.ds.scale = scale; - app.canvas.ds.offset = [-left + (canvas.clientWidth / scale - width) / 2, -top + (canvas.clientHeight / scale - height) / 2]; + app.canvas.ds.offset = [-left + (canvas.clientWidth / scale - width) / 2, -top + inset / scale + (room / scale - height) / 2]; app.canvas.setDirty(true, true); } } From 62089e3c64e4e6ec30dab12adc118c399e0cf0b8 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 16:51:31 +0000 Subject: [PATCH 09/43] Nodes view: Chinese names for the voice reference, dialogue tail and empty prompt switches --- web/freevideo.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/freevideo.js b/web/freevideo.js index 474d19d8..16444409 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -968,8 +968,11 @@ app.registerExtension({ ['continue_from', '接着生成'], ['frames', '帧数'], ['first_frame_reference', '首帧参考', '减小漂移'], + ['voice_reference', '声音参考', '接着生成对白时,沿用原视频开头的声音,让前后声音一致。'], + ['hide_tail', '隐藏对白尾段', '多生成 17 帧后丢弃,让对白在前后两段之间连贯。'], ['step_cache', '步间缓存', '复用相近采样步之间的计算,更快完成,结果会有细微变化。'], ['upscale_factor', '放大倍数', '二次采样时成片相对首遍的放大倍数。'], + ['allow_empty_prompt', '允许空描述', '描述为空时直接生成,不再提示。'], ]) { const widget = this.widgets?.find(w => w.name === name); if (widget) { From fc7c24cb015a248079c90153b35878e7443ef08f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 18:09:28 +0000 Subject: [PATCH 10/43] Connection card: the arrow wraps with its words, a failed lookup shows retry and report only... Connection card: the arrow wraps with its words, a failed lookup shows retry and report only, and the button icon keeps its gap --- web/agent_connect.css | 1 + web/agent_connect.js | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/agent_connect.css b/web/agent_connect.css index f6fd97c5..5d71b2c9 100644 --- a/web/agent_connect.css +++ b/web/agent_connect.css @@ -9,6 +9,7 @@ .fv-agent-connect-body>.fv-muted{margin-top:-10px;font-size:13.5px;color:var(--fv-muted)} .fv-agent-connect-actions{display:flex;align-items:center;gap:12px;margin-top:10px;flex-wrap:wrap} .fv-studio .fv-agent-connect-actions button{padding:9px 20px;font-size:14px}.fv-agent-connect-actions button svg{width:16px;height:16px} +.fv-agent-connect-actions button{display:inline-flex;align-items:center;gap:8px}.fv-agent-connect-next{display:inline-flex;align-items:center;gap:12px;white-space:nowrap}.fv-agent-connect-next[hidden]{display:none} .fv-agent-connect-arrow{color:var(--fv-disabled)}.fv-agent-connect-hint{color:var(--fv-muted);font-size:13.5px} .fv-agent-connect-body .fv-agent-sentence{justify-self:stretch;padding:10px 12px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-bg);color:var(--fv-ink);font-size:var(--fv-body);overflow-wrap:anywhere;user-select:all} /* The Autopilot page: the card in the very middle, or one conversation column under the top bar. */ diff --git a/web/agent_connect.js b/web/agent_connect.js index 33650fc4..6d7ee470 100644 --- a/web/agent_connect.js +++ b/web/agent_connect.js @@ -58,7 +58,8 @@ export function refusalHint(code, t, kind = addressKind(globalThis.location?.hos export async function agentInterfaceEnabled(api) { return await agentInterfaceState(api) === 'on'; } -const ICONS = {plug: ['M9 7V3M15 7V3M7 7h10v4a5 5 0 0 1-10 0V7zM12 16v5', 1.8], copy: ['M11 9h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2zM5 15V5a2 2 0 0 1 2-2h8', 2]}; +const ICONS = {plug: ['M9 7V3M15 7V3M7 7h10v4a5 5 0 0 1-10 0V7zM12 16v5', 1.8], copy: ['M11 9h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2zM5 15V5a2 2 0 0 1 2-2h8', 2], + retry: ['M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', 2]}; function icon(name) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); @@ -81,10 +82,14 @@ export function agentConnectCard(t, api, shown = () => true, changed = () => {}) const subtitle = el('p', 'fv-muted', t('Claude Code, Codex, FreeToken and others', 'Claude Code、Codex、FreeToken 等')); const actions = el('div', 'fv-agent-connect-actions'); const button = el('button', 'fv-primary'); button.type = 'button'; button.disabled = true; - const label = el('span'); button.append(icon('copy'), label); + // The button's picture follows what it does: copy, or retry once the lookup has failed. + const label = el('span'), pictures = {copy: icon('copy'), retry: icon('retry')}; button.append(pictures.copy, label); + for (const [name, picture] of Object.entries(pictures)) picture.dataset.icon = name; let issue = null; const arrow = el('span', 'fv-agent-connect-arrow', '→'); arrow.setAttribute('aria-hidden', 'true'); - actions.append(button, arrow, el('span', 'fv-agent-connect-hint', t('Paste to your agent', '粘贴给 Agent'))); + // The arrow and its words wrap as one piece. + const next = el('span', 'fv-agent-connect-next'); next.append(arrow, el('span', 'fv-agent-connect-hint', t('Paste to your agent', '粘贴给 Agent'))); + actions.append(button, next); const sentence = el('p', 'fv-agent-sentence'); sentence.hidden = true; const status = el('p', 'fv-agent-connect-status', t('Getting the connection address…', '正在获取连接地址…')); status.setAttribute('role', 'status'); body.append(mark, title, intro, subtitle, actions, sentence, status); @@ -95,9 +100,12 @@ export function agentConnectCard(t, api, shown = () => true, changed = () => {}) const active = () => body.isConnected && shown(); const render = () => { label.textContent = !text && failures >= 3 ? t('Could not get address. Retry', '获取失败,重试') : feedback ? t('Copied', '已复制') : t('Copy', '复制'); + const picture = pictures[!text && failures >= 3 ? 'retry' : 'copy']; + if (button.firstChild !== picture) button.firstChild.replaceWith(picture); if (!text && failures >= 3) { if (!issue) { issue = issueButton(t, () => ({where: ['Agent connection', 'Agent 连接'], message: refusal ? `${label.textContent} (HTTP ${refusal.status}${refusal.code ? ' ' + refusal.code : ''}${opened ? ', opened by ' + opened : ''})` : label.textContent, kind: refusal?.code || noAnswer})); button.after(issue); } } else if (issue) { issue.remove(); issue = null; } + next.hidden = !!issue; button.disabled = !text && failures < 3; button.title = !text && failures < 3 ? t('Getting the connection address…', '正在获取连接地址…') : ''; status.hidden = !!text; status.textContent = !text && failures < 3 ? t('Getting the connection address…', '正在获取连接地址…') : !text ? refusalHint(refusal?.code, t) || noAnswerHint(noAnswer, t) : ''; From 62089f4df6f85643ec0e55d54eafe1672be3c3d9 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 18:16:27 +0000 Subject: [PATCH 11/43] Explain missing or damaged model files on the failure card, and name Detect & review before... Explain missing or damaged model files on the failure card, and name Detect & review before Install / repair in page repair hints --- web/editor.js | 4 +++- web/error_panel.js | 8 ++++++++ web/generation_progress.js | 4 ++-- web/repair_hint.js | 6 ++++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/web/editor.js b/web/editor.js index 7b4bab2e..84261b63 100644 --- a/web/editor.js +++ b/web/editor.js @@ -45,7 +45,7 @@ import {SHOWCASE_SCENE} from './entry_showcase.js'; import {createEditorFailures} from './editor_failure.js'; import {fitEditorHeader} from './editor_header_fit.js'; import {optionalText, clonePrompt, allowEmptyPrompt} from './optional_inputs.js'; -import {outputNotWritable} from './error_panel.js'; +import {outputNotWritable, modelTableDamaged} from './error_panel.js'; import {repairAction} from './repair_hint.js'; import {expandCast} from './cast_mention.js'; import {problem as timedProblem, timedText} from './timed_prompt.js'; @@ -104,6 +104,8 @@ export function editorErrorText(value, t = en => en) { return t('The output folder cannot be written to, so the video was not saved. Check that the folder can be written to, then retry.', '输出目录不可写,视频没能保存。请检查这个文件夹的写入权限后重试。'); if (/manifest\.json/i.test(whole) && /No such file|not found|Errno 2\b|FileNotFoundError/i.test(whole)) return t('The model is not fully installed. ', '模型没有装全。') + t(...repairAction()); + if (modelTableDamaged(whole)) + return t('Some of the model files are missing or damaged. ', '部分模型文件缺失或已损坏。') + t(...repairAction()); if (['model_missing', 'missing_model', 'model_not_found'].includes(code) || /(?:model|checkpoint|模型).*(?:not found|missing|不存在|缺失)/i.test(raw)) return t(`Loading the model failed: ${raw || code}`, `加载模型时出错:${raw || code}`); if (['timeout', 'timed_out', 'generation_timeout'].includes(code) || /timed?\s*out|timeout|超时/i.test(raw)) diff --git a/web/error_panel.js b/web/error_panel.js index 2803660e..228d3363 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -155,6 +155,10 @@ const EXPORT_TO_DOWNLOAD = [ ['仍然失败时,请导出报告。', DOWNLOAD_ADVICE[1]], ]; +// Precomputed tables installed with the video model: missing, truncated or damaged. +export const modelTableDamaged = text => /AdaLN table integrity failure|AdaLN table (?:schedule length|shape or dtype) mismatch|(?:Truncated|Invalid) AdaLN tensor|Truncated or oversized AdaLN tensor payload|(?:Incomplete|Nonfinite) AdaLN model asset/i + .test(String(text).slice(0, 65536)); + // A permission error counts as the output folder's only when the path it names is not a model or cache file. export function outputNotWritable(text) { const value = String(text).slice(0, 65536); @@ -455,6 +459,10 @@ export function failureAdvice(value, t, known = knownError(value)) { return row('model-files', ['The model is not fully installed', '模型没有装全'], ['Some of the model files FreeVideo needs are missing.', 'FreeVideo 需要的部分模型文件缺失。'], repairAction()); + if (modelTableDamaged(text)) + return row('model-files', ['The model is not fully installed', '模型没有装全'], + ['Some of the model files FreeVideo needs are missing or damaged.', 'FreeVideo 需要的部分模型文件缺失或已损坏。'], + repairAction()); // The second form is the tokenizers library's own error when its vocab or merges file is gone. if (/FreeVideo text encoder files are missing|`vocab` and `merges` must be both be from memory/i.test(text)) return row('encoder-files', ['The text encoder is not fully installed', '文本编码器没有装全'], diff --git a/web/generation_progress.js b/web/generation_progress.js index 3aadec0d..bda7fdae 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -316,8 +316,8 @@ export function encoderFileText(file, t) { ' Select "Repair" in the launcher under Settings > Environment to restore the tested file; the current file is kept.', '可在启动器“设置 → 环境”中点“修复”恢复已验证的文件,原文件会保留。'); else message += t( - ' Select "Install / repair" in the page settings to restore the tested file; the current file is kept.', - '可在页面设置中点“安装/修复”恢复已验证的文件,原文件会保留。'); + ' In the page settings, select "Detect & review", then "Install / repair" to restore the tested file; the current file is kept.', + '可在页面设置中先点“检测并预览”,再点“安装/修复”恢复已验证的文件,原文件会保留。'); return message; } diff --git a/web/repair_hint.js b/web/repair_hint.js index 5c6cd939..5aafaaf0 100644 --- a/web/repair_hint.js +++ b/web/repair_hint.js @@ -1,5 +1,6 @@ // Where to repair a damaged install depends on whether the launcher started this page. -// U+2060 keeps "环境" and "安装/修复" from breaking across lines in narrow dialogs. +// U+2060 keeps "环境", "检测并预览" and "安装/修复" from breaking across lines in narrow dialogs. +// On the page, Install / repair stays unavailable until Detect & review has run, so the hint names both. let launcher = false; let offlineInstall = false; @@ -10,4 +11,5 @@ export const repairAction = () => offlineInstall ? ['Import the offline package again on the Installation page of the launcher, then retry.', '可在启动器“安\u2060装”页重新导入离线包后重试。'] : launcher ? ['Select "Repair" in the launcher under Settings > Environment, then retry.', '可在启动器“设置 → 环⁠境”里点“修复”后重试。'] - : ['Select "Install / repair" in the page settings, then retry.', '可在页面设置中点“安⁠装⁠/⁠修⁠复”后重试。']; + : ['In the page settings, select "Detect & review", then "Install / repair", and retry.', + '可在页面设置中先点“检⁠测⁠并⁠预⁠览”,再点“安⁠装⁠/⁠修⁠复”后重试。']; From 09ddde42f076d13f002e20df4023f549d7163fa1 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 18:16:47 +0000 Subject: [PATCH 12/43] Settings: plan sizes under 1 GiB show MiB or KiB instead of 0.0 GiB, and sizes just under a unit... Settings: plan sizes under 1 GiB show MiB or KiB instead of 0.0 GiB, and sizes just under a unit boundary carry to the next unit --- web/setup.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/web/setup.js b/web/setup.js index e6a54ada..38ef87b4 100644 --- a/web/setup.js +++ b/web/setup.js @@ -20,6 +20,10 @@ const cn = languageOverride === 'zh' || (languageOverride !== 'en' const t = (en, zh) => cn ? zh : en; const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已装好。要重装,请先点“检测并预览”。'}; const gib = n => Number.isFinite(n) ? (n / 2 ** 30).toFixed(1) + " GiB" : "—"; +// Plan sizes pick their unit, so a few-MB repair does not read as "0.0 GiB". +// The cut-offs sit where rounding would print 1024.0 MiB or 1024 KiB, so those carry to the next unit. +const bytesText = n => !(n > 0 && n < 1023.95 * 2 ** 20) ? gib(n) + : n >= 1023.5 * 2 ** 10 ? (n / 2 ** 20).toFixed(1) + " MiB" : Math.max(1, Math.round(n / 2 ** 10)) + " KiB"; const SPEED_TEST_FAILED = 'The speed test did not finish. Click "Test source speeds".'; const BUSY_LOCK = /using this installation\/GPU lock/; const BUSY_TEXT = {en: 'FreeVideo is busy with another task. Try again when it finishes. If nothing else is running, close duplicate FreeVideo instances, then try again.', @@ -465,11 +469,11 @@ export async function openSetup(options) { const gpu = value.inventory?.selected_gpu || {}, local = value.local_models || {}; const facts = [ [t("GPU", "显卡"), gpu.name || "—"], - [t("Model download", "需下载模型"), gib(value.model_download_bytes)], - [t("Models already present", "安装目录内已有模型"), gib(value.existing_model_bytes_size_matched || 0)], - [t("Local models reused", "复用本地模型"), gib(local.reused_bytes || 0)], - [t("Hardlinked / copied", "硬链接/复制"), `${gib(local.linked_bytes || 0)} / ${gib(local.copy_bytes || 0)}`], - [t("Additional disk estimate", "额外磁盘预估"), gib(value.disks?.reduce((sum, d) => sum + d.needed_bytes, 0))], + [t("Model download", "需下载模型"), bytesText(value.model_download_bytes)], + [t("Models already present", "安装目录内已有模型"), bytesText(value.existing_model_bytes_size_matched || 0)], + [t("Local models reused", "复用本地模型"), bytesText(local.reused_bytes || 0)], + [t("Hardlinked / copied", "硬链接/复制"), `${bytesText(local.linked_bytes || 0)} / ${bytesText(local.copy_bytes || 0)}`], + [t("Additional disk estimate", "额外磁盘预估"), bytesText(value.disks?.reduce((sum, d) => sum + d.needed_bytes, 0))], ]; if (value.prepared_model) facts.push([t("Model", "模型"), `${value.prepared_model.repo} · ${value.prepared_model.scale_granularity === "int8_convrot" ? t("slim ConvRot int8, no local conversion", "精简 ConvRot int8,无需本地转换") From 7b77d5a8413d24f94548ca72ab4e8a489e5e33f9 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 18:17:04 +0000 Subject: [PATCH 13/43] Settings: unavailable buttons share one gray look and no longer react to hover --- web/setup.css | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/setup.css b/web/setup.css index 2572942a..7a52e7d0 100644 --- a/web/setup.css +++ b/web/setup.css @@ -81,13 +81,15 @@ /* Buttons. */ .fv-setup button{background:var(--fv-raised);border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);min-height:var(--fv-h);padding:7px 14px;color:var(--fv-ink);cursor:pointer;font:inherit;font-weight:var(--fv-medium);transition:background .2s,border-color .2s} -.fv-setup button:hover{background:var(--fv-hover)} +.fv-setup button:hover:not(:disabled){background:var(--fv-hover)} .fv-setup button:active:not(:disabled){filter:brightness(.9);transition-duration:.06s} .fv-setup button.fv-primary{background:var(--fv-accent);color:var(--fv-bg);border-color:var(--fv-accent);font-weight:var(--fv-semibold)} -.fv-setup button.fv-primary:hover{background:var(--fv-accent-hover)} +.fv-setup button.fv-primary:hover:not(:disabled){background:var(--fv-accent-hover)} .fv-setup button.fv-quiet{background:transparent;border-color:transparent;color:var(--fv-muted)} -.fv-setup button.fv-quiet:hover{background:var(--fv-raised);color:var(--fv-ink)} -.fv-setup button:disabled{opacity:.45;cursor:default} +.fv-setup button.fv-quiet:hover:not(:disabled){background:var(--fv-raised);color:var(--fv-ink)} +/* Unavailable buttons share one gray look, primary included, and do not react to the pointer. */ +.fv-setup button:disabled{background:transparent;border-color:var(--fv-border);color:var(--fv-disabled);cursor:default} +.fv-setup button.fv-quiet:disabled{border-color:transparent} .fv-setup a{color:var(--fv-accent);text-underline-offset:3px} .fv-setup .fv-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px} .fv-setup .fv-actions.fv-end{justify-content:flex-end} From 3b06aa77cf35babbaba5af7e87bb80c6355a6877 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 18:29:33 +0000 Subject: [PATCH 14/43] Add Open settings to failures that point to page settings repair Failure cards, the editor's failure rows and the dubbing note whose hint says to repair in the page settings now offer a secondary Open settings button. It opens Settings through the existing opener at Detect & review, scrolled into view and focused. Launcher and offline-pack hints are unchanged and show no button. A browser test covers the failure popup and the Studio card in both languages. --- web/editor_genbar.js | 6 +++++- web/editor_tracks.js | 9 ++++++--- web/editor_voice.js | 6 ++++-- web/error_panel.js | 7 +++++-- web/issue_button.js | 3 ++- web/repair_hint.js | 31 +++++++++++++++++++++++++------ web/setup.js | 7 +++++++ 7 files changed, 54 insertions(+), 15 deletions(-) diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 7d14571d..adce3479 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -5,6 +5,7 @@ import {createEditorReferences} from './editor_references.js'; import {createExpandButton} from './editor_expand.js'; import {createRequestAssets} from './request_assets.js'; import {issueButton} from './issue_button.js'; +import {pageRepair, repairButton} from './repair_hint.js'; import {waitingText} from './chain_support.js'; import {showEmptyPrompt} from './empty_prompt_notice.js'; import {segmented} from './editor_adjust.js'; @@ -163,7 +164,9 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { const failure = el('div', 'fv-ed-gen-failure fv-result-links'), failureBar = el('div', 'fv-ed-failure-actions fv-actions'); const failureReason = el('span', 'fv-ed-failure-reason'); failureReason.setAttribute('role', 'status'); const report = el('button', 'fv-ed-failure-report fv-action fv-action-secondary', t('Download report', '下载报告')); report.type = 'button'; - failureBar.append(failureReason, report); failure.append(failureBar); failure.hidden = true; host.append(failure); + // Beside a reason whose hint names the page settings. + const repair = repairButton(t, {className: 'fv-action fv-action-secondary'}); repair.hidden = true; + failureBar.append(failureReason, repair, report); failure.append(failureBar); failure.hidden = true; host.append(failure); let failureId = null, downloading = false; // Reference pictures for a part that starts fresh: thumbnails and a small "+" before the description. // What goes wrong with a reference picture is said above the "+", where the eyes are, not in the page-wide line. @@ -214,6 +217,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { } failure.hidden = !selectedFailure; failureReason.textContent = selectedFailure?.text || ''; + repair.hidden = !pageRepair(selectedFailure?.text); // A piece of music has no generation receipt to report. report.hidden = !selectedFailure || selectedFailure.report === false; const running = ['queued', 'running', 'cancelling'].includes(status?.state); diff --git a/web/editor_tracks.js b/web/editor_tracks.js index a49261ac..6290832f 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -1,4 +1,5 @@ import {editorIcon} from './editor_icons.js'; +import {pageRepair, repairButton} from './repair_hint.js'; import {displayTracks, isText, isTextTrack, textDropLocked} from './editor_text_model.js'; // Pointer gestures translate coordinates into model calls; the model owns edits. // These exports deliberately have no DOM dependencies, so input arithmetic can @@ -675,11 +676,13 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const retry = create('button', '', t('Retry', '重试')); retry.type = 'button'; retry.onclick = () => { popover.hidden = true; Promise.resolve().then(() => actions.retry?.(item.clip.id)).catch(error => actions.editError?.(error, 'generate')); }; - popover.replaceChildren(reason, report, retry); popover.hidden = false; + // A reason whose hint names the page settings also offers to open them. + const repair = pageRepair(reason.textContent) ? repairButton(t, {before: () => { popover.hidden = true; }}) : null; + popover.replaceChildren(...[reason, report, repair, retry].filter(Boolean)); popover.hidden = false; const bounds = element.getBoundingClientRect(), clipBounds = item.element.getBoundingClientRect(); - // The card is as wide as the clip it belongs to (never narrower than before, so its two buttons stay on one row) and sits against the clip's lower edge; + // The card is as wide as the clip it belongs to (never narrower than before, so its buttons stay on one row) and sits against the clip's lower edge; // where that would cover the track below, it opens upward from the clip's top when there is room. - const width = Math.min(Math.max(clipBounds.width, 280), bounds.width - 16); + const width = Math.min(Math.max(clipBounds.width, repair ? 360 : 280), bounds.width - 16); popover.style.width = `${width}px`; popover.style.left = `${clamp(clipBounds.left - bounds.left, 8, Math.max(8, bounds.width - width - 8))}px`; const height = popover.getBoundingClientRect().height, below = clipBounds.bottom - bounds.top + 4, above = clipBounds.top - bounds.top - 4 - height; diff --git a/web/editor_voice.js b/web/editor_voice.js index 36c3f27c..45ef2736 100644 --- a/web/editor_voice.js +++ b/web/editor_voice.js @@ -3,8 +3,8 @@ // the user typed, the progress, the original/new choice for playback and the lines that did not fit. import * as voice from './editor_voice_model.js'; import {isNetworkTypeError, connectionLostText} from './browser_extension.js'; -import {issueButton} from './issue_button.js'; -import {repairAction} from './repair_hint.js'; +import {issueButton, style as issueButtonStyle} from './issue_button.js'; +import {pageRepair, repairAction, repairButton} from './repair_hint.js'; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; @@ -186,6 +186,8 @@ export function createVoicePanel(host, controller, {t}) { note.replaceChildren(); if (failure) { note.append(failure.text); + // A hint that names the page settings opens them; the button looks like the report button beside notes. + if (pageRepair(failure.text)) { issueButtonStyle(); note.append(repairButton(t, {className: 'fv-issue-button'})); } if (failure.report) note.append(issueButton(t, () => ({where: ['Editor generation', '剪辑器生成'], message: failure.text + (current.failure?.detail ? `\n${current.failure.detail}` : ''), kind: current.failure?.code || null}))); } else note.textContent = current.notice === 'changed' ? t('This part changed during dubbing, so the new voice was not placed. Redub it.', '这一段在配音期间有改动,新声未放入。请重新配音。') : current.phase === 'none' ? t('No lines were found in this part.', '这一段没有识别到台词。') : current.phase === 'done' && current.failed ? voice.failedText(current.failed, t) : ''; note.hidden = !note.textContent; diff --git a/web/error_panel.js b/web/error_panel.js index 228d3363..577dcbb3 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -1,6 +1,6 @@ // Shared by the Studio and graph view. Text only; never render exception HTML. import {readOtherUsage, otherUsageText} from './other_usage.js'; -import {repairAction} from './repair_hint.js'; +import {pageRepair, repairAction, repairButton} 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'; @@ -608,9 +608,11 @@ export function createErrorPanel(t, route = null) { // Shown only for a browser extension, whose steps end with a reload. const reload = document.createElement('button'); reload.type = 'button'; reload.className = 'fv-failure-reload'; reload.textContent = t('Reload page', '刷新页面'); reload.hidden = true; reload.onclick = () => globalThis.location?.reload(); + // Shown only when the advice says to repair in the page settings; a failure dialog closes so Settings is on top. + const repair = repairButton(t, {before: () => element.closest('dialog.fv-failure-dialog')?.close()}); repair.hidden = true; const actions = document.createElement('div'); actions.className = 'fv-failure-actions'; // Last in the DOM, first on screen (CSS order), so copy stays the first child. - actions.append(copy, download, reload, github); + actions.append(copy, download, reload, repair, github); const explanation = document.createElement('p'), action = document.createElement('p'); const location = document.createElement('p'), cause = document.createElement('p'); cause.className = 'fv-failure-cause'; location.className = 'fv-failure-location'; @@ -736,6 +738,7 @@ export function createErrorPanel(t, route = null) { if (action.textContent.includes(from)) action.textContent = action.textContent.replace(from, to); } action.hidden = !action.textContent; + repair.hidden = !pageRepair(action.textContent); privacy.textContent = download.hidden ? t('Select "Download report" in the preview to save the full diagnostic information.', '可在预览区点“下载报告”保存完整诊断信息。') : t('Paths and typed text are removed.', '已去掉路径和输入的文字。'); diff --git a/web/issue_button.js b/web/issue_button.js index ecaedb69..6e4cbe9d 100644 --- a/web/issue_button.js +++ b/web/issue_button.js @@ -40,7 +40,8 @@ function icon() { } let sheet = null; -function style() { +// Also used by a plain secondary button that sits beside this one (a note's "Open settings"). +export function style() { if (sheet?.parentNode) return; sheet = document.createElement('style'); sheet.id = 'freevideo-issue-button-style'; sheet.textContent = `.fv-issue-button{display:inline-flex;align-items:center;gap:6px;min-height:var(--fv-h-sm,32px);box-sizing:border-box;border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-sm,8px);background:transparent;color:var(--fv-muted,#bbb);padding:4px 11px;font:inherit;font-size:13px;cursor:pointer} diff --git a/web/repair_hint.js b/web/repair_hint.js index 5aafaaf0..8933158f 100644 --- a/web/repair_hint.js +++ b/web/repair_hint.js @@ -7,9 +7,28 @@ let offlineInstall = false; export const setLauncherPresent = present => { launcher = Boolean(present); }; export const setOfflineInstall = offline => { offlineInstall = offline === true; }; -export const repairAction = () => offlineInstall - ? ['Import the offline package again on the Installation page of the launcher, then retry.', '可在启动器“安\u2060装”页重新导入离线包后重试。'] - : launcher - ? ['Select "Repair" in the launcher under Settings > Environment, then retry.', '可在启动器“设置 → 环⁠境”里点“修复”后重试。'] - : ['In the page settings, select "Detect & review", then "Install / repair", and retry.', - '可在页面设置中先点“检⁠测⁠并⁠预⁠览”,再点“安⁠装⁠/⁠修⁠复”后重试。']; +const OFFLINE = ['Import the offline package again on the Installation page of the launcher, then retry.', '可在启动器“安\u2060装”页重新导入离线包后重试。']; +const LAUNCHER = ['Select "Repair" in the launcher under Settings > Environment, then retry.', '可在启动器“设置 → 环⁠境”里点“修复”后重试。']; +const PAGE = ['In the page settings, select "Detect & review", then "Install / repair", and retry.', + '可在页面设置中先点“检⁠测⁠并⁠预⁠览”,再点“安⁠装⁠/⁠修⁠复”后重试。']; + +export const repairAction = () => offlineInstall ? OFFLINE : launcher ? LAUNCHER : PAGE; + +/** True when the shown text ends with the page-settings repair step (not the launcher's, nor an offline pack's). */ +export const pageRepair = text => PAGE.some(step => String(text || '').includes(step)); + +/** Opens the page settings at Detect & review, through the same opener as the top-bar Settings. */ +export const openRepairSettings = () => import('./setup.js').then(({openSetup}) => openSetup({section: 'repair'})).catch(() => {}); + +/** + * The "Open settings" button shown beside a failure whose hint names the page settings. One component for the + * failure card, the editor's failure rows and the dubbing note; `className` adds the place's own button classes. + * `before` runs first on click (a failure dialog closes itself so the settings dialog is the one on top). + */ +export function repairButton(t, {className = '', before = null} = {}) { + const button = document.createElement('button'); button.type = 'button'; + button.className = `fv-repair-open ${className}`.trim(); + button.textContent = t('Open settings', '打开设置'); + button.onclick = () => { before?.(); return openRepairSettings(); }; + return button; +} diff --git a/web/setup.js b/web/setup.js index 38ef87b4..79f83c62 100644 --- a/web/setup.js +++ b/web/setup.js @@ -284,6 +284,13 @@ export async function openSetup(options) { dialog.addEventListener('close', stopStatusWatching); status.append(statusLabel); const showSection = name => { + // 'repair': a failure card's "Open settings" lands on Detect & review, the first step its hint names. + if (name === 'repair') { + installation.open = true; + inspect.scrollIntoView({block: 'center'}); + inspect.focus({preventScroll: true}); + return; + } if (name !== 'preview') return; preview.scrollIntoView({block: 'center'}); loopSwitch.focus({preventScroll: true}); From 9f54b23f7dbc4f0291a64b8842c8c3ab640c637f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 18:43:53 +0000 Subject: [PATCH 15/43] Studio queue: a read answered with a web page instead of data is handled like a lost connection The queue and history reads checked only the status before parsing JSON. A page shown while the engine restarts, or one put in front of it by a proxy or tunnel, has a success status, so parsing threw a syntax error that skipped the retry and the notice that clears itself and stopped a repeating request on the first read. Such a reply is now raised like a lost connection, with the status, content type and kind of body in the details. --- web/browser_extension.js | 18 ++++++++++++++++++ web/studio_queue.js | 8 ++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/web/browser_extension.js b/web/browser_extension.js index 928db4d4..a3afe59b 100644 --- a/web/browser_extension.js +++ b/web/browser_extension.js @@ -50,6 +50,24 @@ export function isNetworkTypeError(error) { // One sentence for a dropped connection (engine closed, network lost); the raw // browser wording stays in the technical details. export function connectionLostText(t) { return t('Lost the connection to FreeVideo. Try again once it is back.', '与 FreeVideo 的连接已断开,恢复后请重试。'); } +// A read the engine did not answer: the status was a success but the body is a web page (a page shown while the +// engine restarts, or one put in front of it by a proxy or tunnel), not JSON. It is raised as the same kind of +// error as a lost connection, so retries, the delay between polls and the notice that clears itself apply +// unchanged. Only the status, the content type and the kind of body are kept; the body itself never is. +export async function readEngineJson(response) { + // A reply object without a readable body text (a test double) is parsed as before. + if (typeof response?.text !== 'function') return response.json(); + const text = await response.text(); + try { return JSON.parse(text); } catch (parse) { + if (!(parse instanceof SyntaxError)) throw parse; + const type = string(response.headers?.get?.('content-type')).split(';')[0].trim().toLowerCase().slice(0, 60) || 'none'; + const body = /^\s* row[1] === reply.prompt_id)) await cancel(reply.prompt_id); + if (response.ok && (await readEngineJson(response)).queue_pending?.some(row => row[1] === reply.prompt_id)) await cancel(reply.prompt_id); } } api.dispatchCustomEvent?.('promptQueued', {number: 0, batchCount: 1}); @@ -214,7 +214,7 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 if (runningAll.some(row => row.id === session.waiting) || pending.some(row => row.id === session.waiting)) return; const response = await fetchQueueRead(api, '/history/' + encodeURIComponent(session.waiting)); if (!response.ok) throw new Error('queue_unavailable'); - const history = (await response.json())[session.waiting]; + const history = (await readEngineJson(response))[session.waiting]; if (loop !== session || !session.active) return; result(session.waiting, history?.outputs?.[nodeId]); // A missing entry can mean a deleted queued request or cleared history. @@ -231,7 +231,7 @@ export function createStudioQueue(api, nodeId, {random = randomSeed, pollMs = 20 async function readQueue() { const response = await fetchQueueRead(api, '/queue'); if (!response.ok) throw new Error('queue_unavailable'); - const queue = await response.json(); + const queue = await readEngineJson(response); runningAll = (queue.queue_running || []).filter(matches).map(entry); running = runningAll[0] || null; // Comfy returns its heap array; the queue number gives the real running order. From 0a250244b40141c6c9ed534a0e463654fab88127 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 18:37:22 +0000 Subject: [PATCH 16/43] Keep model row phrases whole when the setup plan wraps --- web/setup.css | 1 + web/setup.js | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/web/setup.css b/web/setup.css index 7a52e7d0..f7b946ec 100644 --- a/web/setup.css +++ b/web/setup.css @@ -105,6 +105,7 @@ .fv-setup .fv-facts{display:grid;grid-template-columns:auto minmax(0,1fr);gap:6px 18px;margin:4px 0 12px;font-size:var(--fv-body)} .fv-setup .fv-facts dt{color:var(--fv-muted)} .fv-setup .fv-facts dd{margin:0;font-variant-numeric:tabular-nums;overflow-wrap:anywhere} +.fv-setup .fv-facts .fv-fact-part{display:inline-block;max-width:100%;white-space:pre-wrap} .fv-setup table.fv-speed{width:100%;margin-top:12px;border-collapse:collapse;font-size:var(--fv-body);font-variant-numeric:tabular-nums} .fv-setup table.fv-speed th{font-weight:var(--fv-medium);font-size:var(--fv-micro);color:var(--fv-muted);text-align:left;padding:0 8px 6px 0;border-bottom:1px solid var(--fv-border)} .fv-setup table.fv-speed td{padding:7px 8px 7px 0;border-bottom:1px solid var(--fv-border)} diff --git a/web/setup.js b/web/setup.js index 79f83c62..18066948 100644 --- a/web/setup.js +++ b/web/setup.js @@ -482,11 +482,16 @@ export async function openSetup(options) { [t("Hardlinked / copied", "硬链接/复制"), `${bytesText(local.linked_bytes || 0)} / ${bytesText(local.copy_bytes || 0)}`], [t("Additional disk estimate", "额外磁盘预估"), bytesText(value.disks?.reduce((sum, d) => sum + d.needed_bytes, 0))], ]; - if (value.prepared_model) facts.push([t("Model", "模型"), `${value.prepared_model.repo} · ${value.prepared_model.scale_granularity === "int8_convrot" - ? t("slim ConvRot int8, no local conversion", "精简 ConvRot int8,无需本地转换") - : t("slim FP8, no local conversion", "精简 FP8,无需本地转换")}`]); + // Segments wrap as whole units, so a phrase never leaves its last character alone on the next line. + if (value.prepared_model) facts.push([t("Model", "模型"), [`${value.prepared_model.repo} · `, value.prepared_model.scale_granularity === "int8_convrot" + ? t("slim ConvRot int8, ", "精简 ConvRot int8,") : t("slim FP8, ", "精简 FP8,"), + t("no local conversion", "无需本地转换")]]); const list = node('dl'); list.className = 'fv-facts'; - for (const [key, shown] of facts) list.append(node('dt', key), node('dd', shown)); + for (const [key, shown] of facts) { + const cell = node('dd', Array.isArray(shown) ? '' : shown); + if (Array.isArray(shown)) cell.append(...shown.map(part => { const e = node('span', part); e.className = 'fv-fact-part'; return e; })); + list.append(node('dt', key), cell); + } plan.append(list); for (const line of value.replace_models_text?.[cn ? 'zh' : 'en'] || []) { const replacement = node('div'); replacement.className = 'fv-model-repair'; From 78a4596ccb6e0a87f5822f86cdd63867ae05a038 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 19:15:24 +0000 Subject: [PATCH 17/43] Keep the setup agreement's closing phrase whole when it wraps --- web/setup.css | 2 ++ web/setup.js | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/web/setup.css b/web/setup.css index f7b946ec..2809b8e5 100644 --- a/web/setup.css +++ b/web/setup.css @@ -76,6 +76,8 @@ .fv-setup input.fv-minutes:not([type=checkbox]):not([type=range]){display:inline-block;width:96px;margin:0 6px} .fv-setup textarea{resize:vertical;line-height:1.6} .fv-setup label.fv-check{display:flex;align-items:flex-start;gap:9px;color:var(--fv-ink)} +.fv-setup label.fv-check > span{text-wrap:pretty} +.fv-setup label.fv-check .fv-check-phrase{white-space:nowrap} .fv-setup input[type=checkbox]{margin:3px 0 0} .fv-setup input[type=range]{display:block;width:100%;margin:8px 0 4px;accent-color:var(--fv-accent)} diff --git a/web/setup.js b/web/setup.js index 18066948..2162640d 100644 --- a/web/setup.js +++ b/web/setup.js @@ -204,7 +204,10 @@ export async function openSetup(options) { const more = node("details"); more.append(node("summary", t("Details and retained logs", "详细信息与保留日志"))); const log = node("pre"); more.append(log); installation.append(overall, state, bar, counters, detail, plan, more); const acceptLabel = node("label"); acceptLabel.className = 'fv-check'; const accept = node("input"); accept.type = "checkbox"; - acceptLabel.append(accept, node('span', t("I accept the displayed installation plan and model/toolkit licenses", "我同意当前展示的安装计划和模型/工具包许可证"))); + // The closing phrase wraps as one unit, so no character is left alone on the next line. + const acceptText = node('span', t("I accept the displayed installation plan and model/toolkit licenses", "我同意当前展示的安装计划和")); + if (cn) { const phrase = node('span', '模型/工具包许可证'); phrase.className = 'fv-check-phrase'; acceptText.append(phrase); } + acceptLabel.append(accept, acceptText); acceptLabel.hidden = true; installation.append(acceptLabel); const actions = node("div"); actions.className = "fv-actions"; const inspect = node("button", t("Detect & review", "检测并预览")); inspect.className = "fv-primary"; From 3c0c41a9a88b815058a76e04fd2a007e71a4cca8 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 19:27:54 +0000 Subject: [PATCH 18/43] Take reference picture limits from each image model's engine row The picture page reads how many references each model takes from the engine instead of fixed numbers, so H3 Image opens up once the engine passes pictures on. Over a model's limit, Generate stays solid and points to the extra pictures. The note for pictures left out now stays visible, and two-digit labels fit their cells. --- freevideo_engine/image_gen/service.py | 3 +- web/image_view.css | 5 ++- web/image_view.js | 56 +++++++++++++++++---------- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 0a873000..35678806 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -26,7 +26,8 @@ BODY_LIMIT = 64 * 1024 CANCEL_GRACE = 5. MAX_IMAGES = 4 -H3_MAX_IMAGES = 16 +# Reference pictures H3 Image really takes; 0 until the engine passes them on (it then takes 16, as the model does). +H3_MAX_IMAGES = 0 H3_MAX_AREA = image_model.H3_AREAS['high'] ** 2 H3_MAX_SIDE = 8192 H3_GROUP = 'h3-image:' + image_pack.default_tier() diff --git a/web/image_view.css b/web/image_view.css index c95d1d7d..41c52364 100644 --- a/web/image_view.css +++ b/web/image_view.css @@ -11,7 +11,7 @@ .fv-image-reference{position:relative;display:block;width:64px;height:64px} .fv-studio .fv-image-reference-use{width:100%;height:100%;min-height:0;padding:0;overflow:hidden;border-radius:var(--fv-r-sm);position:relative} .fv-image-reference[data-over=true]{filter:grayscale(1);opacity:.55} -.fv-image-reference-use>b{position:absolute;left:4px;bottom:4px;padding:0 5px;border-radius:var(--fv-r-xs);background:var(--fv-bg);color:var(--fv-ink);font-size:var(--fv-micro)} +.fv-image-reference-use>b{position:absolute;left:3px;bottom:3px;padding:0 3px;border-radius:var(--fv-r-xs);background:var(--fv-bg);color:var(--fv-ink);font-size:var(--fv-micro);white-space:nowrap} .fv-studio .fv-image-reference-remove{position:absolute;top:2px;right:2px;width:20px;height:20px;min-height:0;padding:0;border-radius:50%;background:var(--fv-bg);line-height:1;opacity:0;transition:opacity .15s} .fv-image-reference:hover .fv-image-reference-remove,.fv-image-reference:focus-within .fv-image-reference-remove{opacity:1} @media(hover:none){.fv-studio .fv-image-reference-remove{opacity:1}} @@ -19,6 +19,9 @@ .fv-image img{display:block} .fv-image-reference img,.fv-image-work img{width:100%;height:100%;object-fit:cover} .fv-image-notice,.fv-image-note{margin:0;color:var(--fv-danger);font-size:var(--fv-body)}.fv-image-notice:empty,.fv-image-note:empty{display:none} +.fv-image-notice-flash{animation:fv-image-notice-flash .9s ease-in-out} +@keyframes fv-image-notice-flash{0%,100%{opacity:1}25%,75%{opacity:.25}50%{opacity:1}} +@media(prefers-reduced-motion:reduce){.fv-image-notice-flash{animation:none}} .fv-image-shape{display:flex;gap:8px;align-items:center}.fv-image-shape>.fv-select-menu-host{flex:1;min-width:0} .fv-studio .fv-image-go{height:44px;width:100%;font-size:var(--fv-strong)} .fv-studio .fv-image-go-quiet{background:var(--fv-card);color:var(--fv-ink);border:1px solid var(--fv-border);border-radius:var(--fv-r-md);cursor:pointer} diff --git a/web/image_view.js b/web/image_view.js index 57e87aa9..6ff22c85 100644 --- a/web/image_view.js +++ b/web/image_view.js @@ -29,8 +29,8 @@ for (const name of ['./editor_adjust.css', './image_view.css']) { } export const IMAGE_MODELS = Object.freeze([ - {id: 'minimax-h3-image', name: 'MiniMax H3 Image', short: 'H3 Image', references: 0}, - {id: 'qwen-image-2.1-turbo', name: 'Qwen-Image 2.1', short: 'Qwen-Image 2.1', references: 3}, + {id: 'minimax-h3-image', name: 'MiniMax H3 Image', short: 'H3 Image'}, + {id: 'qwen-image-2.1-turbo', name: 'Qwen-Image 2.1', short: 'Qwen-Image 2.1'}, ]); export const RATIOS = Object.freeze(['1:1', '4:3', '3:4', '16:9', '9:16', '3:2', '2:3']); const MODEL_KEY = 'freevideo.imageModel'; @@ -50,17 +50,19 @@ export function imageSize(ratio, tier = 'standard', same = null, model = '') { } // Stands in for the engine until it answers (tests and the first skeleton): a flat picture after a short wait. -export function createFakeImageClient({delay = 600, ready = {'minimax-h3-image': true, 'qwen-image-2.1-turbo': true}, fail = null, steps = 0} = {}) { +// references: how many pictures each model row reports it takes, as the engine does. +export function createFakeImageClient({delay = 600, ready = {'minimax-h3-image': true, 'qwen-image-2.1-turbo': true}, fail = null, steps = 0, references = {'minimax-h3-image': 0, 'qwen-image-2.1-turbo': 4}} = {}) { const items = []; let count = 0; const jobs = new Map(), state = {...ready}; const loading = new Map(); - const models = () => IMAGE_MODELS.map(m => ({id: m.id, label: m.name, ready: !!state[m.id], downloading: loading.has(m.id), + const models = () => IMAGE_MODELS.map(m => ({id: m.id, label: m.name, ready: !!state[m.id], downloading: loading.has(m.id), max_images: references[m.id] ?? 0, bytes_total: 12.5e9, bytes_done: loading.has(m.id) ? Math.min(12.5e9, (Date.now() - loading.get(m.id)) * 5e6) : 0})); return { info: async () => { for (const [id, at] of loading) if (Date.now() - at > 12.5e9 / 5e6) { state[id] = true; loading.delete(id); } return {installed: true, ready: true, max_images: 16, models: models()}; }, packInstall: async id => { loading.set(id, Date.now()); return {installed: true, models: models()}; }, packCancel: async id => { loading.delete(id); return {installed: true, models: models()}; }, - async generate({prompt, width, height, seed}) { + async generate({prompt, width, height, seed, model, images = []}) { if (fail) throw Object.assign(new Error(fail), {code: fail}); - const job = 'fake-' + ++count; jobs.set(job, {at: Date.now(), prompt, width, height, seed}); (this.requests ||= []).push({prompt, width, height, seed}); return {job}; + if (images.length > (references[model] ?? 0)) return {error: 'too_many_images'}; + const job = 'fake-' + ++count; jobs.set(job, {at: Date.now(), prompt, width, height, seed}); (this.requests ||= []).push({prompt, width, height, seed, model, images}); return {job}; }, async status(job) { const row = jobs.get(job), fraction = Math.min(1, (Date.now() - row.at) / delay); @@ -223,17 +225,22 @@ export function openImageView(node, {studio = null, client = createImageGenClien const say = text => { note.textContent = text || ''; }; const label = number => t(`Image ${number}`, `图 ${number}`); - const over = () => Math.max(0, references.length - model.references); + // Each model row says how many pictures it takes; the strip holds as many as the most generous one. + const cap = m => { const n = Number(info?.models?.find(r => r.id === m.id)?.max_images); return Number.isInteger(n) && n > 0 ? n : 0; }; + const most = () => Math.max(0, ...IMAGE_MODELS.map(cap)); + const over = () => Math.max(0, references.length - cap(model)); function addFiles(files, card) { - if (model.references === 0) return; - const room = Math.max(0, 16 - references.length); let skipped = 0; + if (cap(model) === 0) return; + const room = Math.max(0, most() - references.length); let skipped = 0; for (const file of files) { if (!file.type.startsWith('image/')) continue; - if (references.length >= 16) { skipped++; continue; } + if (references.length >= most()) { skipped++; continue; } const record = {file, url: URL.createObjectURL(file), card}; references.push(record); const probe = new Image(); probe.onload = () => { record.width = probe.naturalWidth; record.height = probe.naturalHeight; if (alive) render(); }; probe.src = record.url; } - notice.textContent = skipped ? t(`At most 16, ${skipped} more were not added.`, `最多 16 张,多出的 ${skipped} 张没有加入。`) : ''; + // The note stays until the strip changes again. + notice.dataset.keep = String(skipped > 0); + notice.textContent = skipped ? t(`At most ${most()}, ${skipped} more were not added.`, `最多 ${most()} 张,多出的 ${skipped} 张没有加入。`) : ''; void room; render(); } async function useAsReference(file, first) { @@ -241,7 +248,7 @@ export function openImageView(node, {studio = null, client = createImageGenClien try { const blob = await (await fetch(pictureURL(file))).blob(), name = file.slice(file.lastIndexOf('/') + 1); const record = {file: new File([blob], name, {type: blob.type || 'image/png'}), url: URL.createObjectURL(blob)}; - if (references.length >= 16) { URL.revokeObjectURL(record.url); return; } + if (references.length >= most()) { URL.revokeObjectURL(record.url); return; } first ? references.unshift(record) : references.push(record); const probe = new Image(); probe.onload = () => { record.width = probe.naturalWidth; record.height = probe.naturalHeight; if (alive) render(); }; probe.src = record.url; render(); prompt.focus(); @@ -260,18 +267,18 @@ export function openImageView(node, {studio = null, client = createImageGenClien function drawReferences() { strip.replaceChildren(...references.map((r, at) => { const cell = el('span', 'fv-image-reference'), use = el('button', 'fv-image-reference-use'), x = el('button', 'fv-image-reference-remove', '×'); - cell.dataset.over = String(at >= model.references); + cell.dataset.over = String(at >= cap(model)); use.type = x.type = 'button'; use.title = t(`Refer to ${label(at + 1)} in the prompt`, `在描述里引用${label(at + 1)}`); use.setAttribute('aria-label', use.title); x.title = t('Remove', '移除'); x.setAttribute('aria-label', x.title); const i = el('img'); i.src = r.url; i.alt = ''; use.append(i, el('b', '', label(at + 1))); use.onclick = () => insertTag(at + 1); - x.onclick = () => { URL.revokeObjectURL(r.url); references.splice(references.indexOf(r), 1); render(); }; + x.onclick = () => { notice.dataset.keep = 'false'; URL.revokeObjectURL(r.url); references.splice(references.indexOf(r), 1); render(); }; cell.append(use, x); return cell; })); - if (model.references > 0 && references.length < 16) { + if (cap(model) > 0 && references.length < most()) { const add = el('button', 'fv-image-add', '+'); add.type = 'button'; add.setAttribute('aria-label', t('Add a reference picture', '添加参考图')); add.onclick = () => { if (menu?.box.isConnected) { menu.close(); return; } menu = openReferenceMenu({api, viewURL: pictureURL, anchor: () => dialog.querySelector('.fv-image-add') || add, host: dialog, onComputer: () => pick.click(), - onPick: (file, id) => { addFiles([file], id); }, state: () => ({used: new Set(references.map(r => r.card)), full: references.length >= 16})}); }; - if (references.length < model.references || over() === 0) strip.append(add); + onPick: (file, id) => { addFiles([file], id); }, state: () => ({used: new Set(references.map(r => r.card)), full: references.length >= most()})}); }; + if (references.length < cap(model) || over() === 0) strip.append(add); } } pick.onchange = () => { addFiles([...pick.files]); pick.value = ''; }; @@ -304,13 +311,14 @@ export function openImageView(node, {studio = null, client = createImageGenClien const tooMany = over() > 0, busy = !!running, canUse = !missing(); prompt.placeholder = references.length ? t('What to make or change', '要画什么或要改什么') : t('Describe the picture', '描述画面'); prompt.setAttribute('aria-label', prompt.placeholder); - notice.textContent = tooMany ? (model.references === 0 ? t(`${model.name} does not take reference pictures. Remove ${over()}.`, `${model.name} 暂不支持参考图,请移除 ${over()} 张。`) : t(`${model.name} takes at most ${model.references}. Remove ${over()}.`, `${model.name} 最多 ${model.references} 张,请移除 ${over()} 张。`)) : notice.dataset.keep === 'true' ? notice.textContent : ''; + if (tooMany) notice.dataset.keep = 'false'; + notice.textContent = tooMany ? (cap(model) === 0 ? t(`${model.name} does not take reference pictures. Remove ${over()}.`, `${model.name} 暂不支持参考图,请移除 ${over()} 张。`) : t(`${model.name} takes at most ${cap(model)}. Remove ${over()}.`, `${model.name} 最多 ${cap(model)} 张,请移除 ${over()} 张。`)) : notice.dataset.keep === 'true' ? notice.textContent : ''; go.className = 'fv-image-go ' + (failure ? 'fv-image-go-quiet' : 'fv-primary'); go.disabled = false; if (busy) go.textContent = t('Stop', '停止'); else if (loading()) go.textContent = t('Pause', '暂停'); else if (missing()) go.textContent = t('Download model', '下载模型'); - else { go.textContent = t('Generate', '生成'); go.disabled = tooMany; } + else { go.textContent = t('Generate', '生成'); go.disabled = false; } go.removeAttribute('title'); seedInput.disabled = busy; drawStage(); } @@ -336,7 +344,7 @@ export function openImageView(node, {studio = null, client = createImageGenClien if (mine) doneReport.append(imageReportBar(doneReport, mine, t, {productVersion: () => null, quiet: true, fetchApi: (path, options) => api.fetchApi(path, options)}).bar); } picture.src = pictureURL(shownFile); save.href = pictureURL(shownFile); save.setAttribute('download', shownFile.slice(shownFile.lastIndexOf('/') + 1)); - editThis.disabled = asReference.disabled = references.length >= 16 || model.references === 0; + editThis.disabled = asReference.disabled = references.length >= most() || cap(model) === 0; infoText.textContent = [model.short, picture.naturalWidth ? `${picture.naturalWidth}×${picture.naturalHeight}` : '', started ? t(`${started} s`, `${started} 秒`) : ''].filter(Boolean).join(' · '); } } @@ -370,7 +378,13 @@ export function openImageView(node, {studio = null, client = createImageGenClien await new Promise(r => setTimeout(r, 1500)); try { info = await client.info(); } catch { /* asked again */ } render(); } } + // Over the model's limit, Generate stays solid: a click flashes the note and moves to the first greyed picture's remove. + function blockOver() { + notice.classList.remove('fv-image-notice-flash'); void notice.offsetWidth; notice.classList.add('fv-image-notice-flash'); + strip.querySelector('.fv-image-reference[data-over=true] .fv-image-reference-remove')?.focus(); + } async function generate() { + if (over()) { blockOver(); return; } if (!seedInput.checkValidity()) { advanced.open = true; seedInput.reportValidity(); return; } const seed = seedInput.value === '' ? undefined : Number(seedInput.value); say(''); failure = null; const mine = running = {cancelled: false}, begun = Date.now(), withImages = references.length > 0, sent = size(), tierNow = tier; @@ -379,7 +393,7 @@ export function openImageView(node, {studio = null, client = createImageGenClien clearStep(); let reportId = null, stepSerial = 0; try { - const names = []; for (const r of references.slice(0, model.references)) names.push(await client.upload(r.file)); + const names = []; for (const r of references.slice(0, cap(model))) names.push(await client.upload(r.file)); if (mine.cancelled) return; if (!enhancerOff() && await enhancer.beforeGenerate() === false) { say(''); return; } const row = await client.generate({prompt: engineText(), ...sent, model: model.id, remember: true, images: names, ...(seed === undefined ? {} : {seed})}); From c5f09746378b547b22104768e1f468a5249341fb Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 20:34:49 +0000 Subject: [PATCH 19/43] Editor library: thumbnails carry the API prefix once Library-list cards stored an already-prefixed thumbnail address, and the library prefixed it again, so behind ComfyUI's /api prefix the thumbnails failed to load. Store the plain route like server-sent sources. Adds a browser check with a prefixing API in Chinese and English at 1440 and 400 wide. --- web/editor_store.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/editor_store.js b/web/editor_store.js index 927dced3..bbf16bb4 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -428,7 +428,8 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener for (const row of value.items || []) if (row.video && row.id && !sources.has(`work:${row.id}`)) { fallback[`work:${row.id}`] = {...row.geometry, _fallback: true, video: mediaURL(api, row.video), audio: mediaURL(api, row.video), has_video: true, has_audio: row.has_audio !== false, path: row.video, missing: false, - thumbnail: api.apiURL?.('/freevideo/library/thumbnail?' + new URLSearchParams({id: row.id})) || '/freevideo/library/thumbnail?' + new URLSearchParams({id: row.id})}; + // Stored like server-sent sources: a plain route that readers prefix once. + thumbnail: '/freevideo/library/thumbnail?' + new URLSearchParams({id: row.id})}; } if (Object.keys(fallback).length) cache(fallback); return value; From bba9bce0b7b59f5538053ceef255ebee92729037 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 21:39:56 +0000 Subject: [PATCH 20/43] Keep running when Windows application control refuses a SciPy file, and recognise the refusal in... Keep running when Windows application control refuses a SciPy file, and recognise the refusal in any system language Transformers and Diffusers treat SciPy as optional but import it unconditionally once it is installed; a refused SciPy file stopped the GPU check and every generation. Both now see SciPy as not installed when it cannot be loaded. A file Windows refuses to load is identified by its error code instead of its system-language text, and the GPU check shows the Windows protection card for it. The CosyVoice vocoder imported SciPy at module load, so a refused SciPy file stopped dubbing before any audio was made. Every audio use of SciPy now loads it when needed and reports a refusal with the Windows error code, so it shows the same card as other refused files. --- freevideo_engine/agent/sound.py | 3 +- freevideo_engine/agent/voice.py | 3 +- freevideo_engine/app_control.py | 131 ++++++++++++++++++ freevideo_engine/audio_loudness.py | 10 +- .../dubbing/cosyvoice3/hifigan/generator.py | 6 +- freevideo_engine/failure_details.py | 4 +- freevideo_engine/paths.py | 2 + freevideo_engine/probe.py | 3 +- 8 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 freevideo_engine/app_control.py diff --git a/freevideo_engine/agent/sound.py b/freevideo_engine/agent/sound.py index 4c5d1976..edf40c5d 100644 --- a/freevideo_engine/agent/sound.py +++ b/freevideo_engine/agent/sound.py @@ -22,6 +22,7 @@ from .. import comfy_editor from .jobs import JobError +from ..app_control import scipy_module FOLDER = 'freevideo-sound' RATE = 48000 @@ -382,7 +383,7 @@ def score_like(samples, rate): some effects with the music; shots, explosions and crowds are broadband, so they fail the tonal test, and short stings fail the sounding one.""" import numpy as np - from scipy.ndimage import median_filter + median_filter = scipy_module('scipy.ndimage').median_filter mono = np.asarray(samples, np.float64).mean(axis=0) rms = float(np.sqrt(np.mean(mono ** 2))) if mono.size else 0. half = rate // 2 diff --git a/freevideo_engine/agent/voice.py b/freevideo_engine/agent/voice.py index 23010e3e..16778ffd 100644 --- a/freevideo_engine/agent/voice.py +++ b/freevideo_engine/agent/voice.py @@ -18,6 +18,7 @@ import numpy as np from .jobs import JobError +from ..app_control import scipy_module from .stems import FOLDER as STEMS_FOLDER, _busy, run_stems FOLDER = 'freevideo-voice' @@ -299,7 +300,7 @@ def _read(path, first, last): def _resample(samples, rate): if rate == RATE: return np.asarray(samples, dtype=np.float32) - from scipy.signal import resample_poly + resample_poly = scipy_module('scipy.signal').resample_poly divisor = gcd(RATE, int(rate)) return resample_poly(np.asarray(samples, dtype=np.float32), RATE // divisor, int(rate) // divisor).astype(np.float32) diff --git a/freevideo_engine/app_control.py b/freevideo_engine/app_control.py new file mode 100644 index 00000000..7bb6a5e5 --- /dev/null +++ b/freevideo_engine/app_control.py @@ -0,0 +1,131 @@ +"""Windows application control (Smart App Control) refusing unsigned extension files. + +Windows words its refusal in the system language, so text matching alone misses +it on a non-English system. ``describe`` asks Windows for the error code of the +file that failed to load and adds the code to the message. ``tolerate_blocked_scipy`` +keeps the optional SciPy use inside Transformers and Diffusers from stopping a +run when one of SciPy's files is refused. +""" +import importlib +import importlib.abc +import importlib.machinery +import sys +import threading + +# ERROR_ACCESS_DISABLED_BY_POLICY, the code behind "An Application Control policy has blocked this file." +BLOCKED_BY_POLICY = 4551 + +# The SciPy modules each library imports when it finds SciPy. +SCIPY_USERS = { + 'transformers.utils.import_utils': ('scipy.optimize',), + 'diffusers.utils.import_utils': ('scipy.stats', 'scipy.integrate'), +} + +_lock = threading.Lock() +_installed = False +_unusable = {} + + +def blocked_file(error): + """The path Windows refused to load for this ImportError, or None.""" + if sys.platform != 'win32' or not isinstance(error, ImportError) or not getattr(error, 'path', None): + return None + if 'DLL load failed' not in str(error): + return None + try: + import ctypes + ctypes.WinDLL(str(error.path)) + except OSError as refusal: + return str(error.path) if getattr(refusal, 'winerror', None) == BLOCKED_BY_POLICY else None + return None + + +def describe(error): + """The error text, with the Windows code appended when application control refused the file.""" + text = str(error) + path = blocked_file(error) + if path: + text += ' [WinError %d: Windows application control blocked %s]' % (BLOCKED_BY_POLICY, path) + return text + + +def scipy_module(name): + """Import a SciPy module when a feature needs it; a refused file is reported with its Windows code.""" + try: + return importlib.import_module(name) + except ImportError as error: + text = describe(error) + if text == str(error): + raise + raise ImportError(text, name=error.name, path=error.path) from error + + +def scipy_problem(): + """Why SciPy was set aside in this process, or None.""" + return _unusable.get('scipy') + + +def _checked(name, original, modules): + def available(): + if not original(): + return False + if 'scipy' not in _unusable: + try: + for module in modules: + importlib.import_module(module) + except ImportError as error: + _unusable['scipy'] = describe(error) + print('FreeVideo: SciPy cannot be loaded (%s); continuing without it.' % _unusable['scipy'], + file=sys.stderr, flush=True) + return 'scipy' not in _unusable + available.__name__ = original.__name__ + available.freevideo_checked = True + return available + + +class _Patch(importlib.abc.Loader): + def __init__(self, loader): + self.loader = loader + + def create_module(self, spec): + return self.loader.create_module(spec) + + def exec_module(self, module): + self.loader.exec_module(module) + original = getattr(module, 'is_scipy_available', None) + if callable(original) and not hasattr(original, 'freevideo_checked'): + module.is_scipy_available = _checked(module.__name__, original, SCIPY_USERS[module.__name__]) + + +class _Finder(importlib.abc.MetaPathFinder): + def find_spec(self, name, path, target=None): + if name not in SCIPY_USERS: + return None + spec = importlib.machinery.PathFinder.find_spec(name, path) + if spec is not None and spec.loader is not None: + spec.loader = _Patch(spec.loader) + return spec + + +def tolerate_blocked_scipy(): + """Treat SciPy as not installed in Transformers and Diffusers once one of its files cannot be loaded. + + Both libraries use SciPy only for optional parts (detection losses, extra + noise schedules) and gate it on ``is_scipy_available``; a file that Windows + refuses is an ImportError at that gate otherwise. The check imports the + modules they would import next, so nothing is loaded that was not needed. + """ + global _installed + with _lock: + if _installed: + return + _installed = True + for name in SCIPY_USERS: + module = sys.modules.get(name) + original = getattr(module, 'is_scipy_available', None) + if callable(original) and not hasattr(original, 'freevideo_checked'): + checked = module.is_scipy_available = _checked(name, original, SCIPY_USERS[name]) + package = sys.modules.get(name.rpartition('.')[0]) + if getattr(package, 'is_scipy_available', None) is original: + package.is_scipy_available = checked # imported already by the package's own namespace + sys.meta_path.insert(0, _Finder()) diff --git a/freevideo_engine/audio_loudness.py b/freevideo_engine/audio_loudness.py index 983577ca..081693be 100644 --- a/freevideo_engine/audio_loudness.py +++ b/freevideo_engine/audio_loudness.py @@ -6,6 +6,8 @@ import numpy as np +from .app_control import scipy_module + ABSOLUTE_GATE = -70. RELATIVE_GATE = -10. BLOCK_SECONDS, STEP_SECONDS = .4, .1 # 400 ms blocks with 75 % overlap @@ -40,7 +42,7 @@ def _planar(samples): def _block_powers(samples, rate, seconds, step=STEP_SECONDS): """Mean square of the K-weighted signal, summed over channels, for each window of `seconds` every `step`.""" - from scipy.signal import lfilter + lfilter = scipy_module('scipy.signal').lfilter weighted = _planar(samples) for b, a in _k_filters(rate): weighted = lfilter(b, a, weighted, axis=1) @@ -74,7 +76,7 @@ def short_term(samples, rate, step=STEP_SECONDS): def true_peak(samples, rate): """Highest absolute sample after 4x oversampling, in dBTP (-inf for silence).""" - from scipy.signal import resample_poly + resample_poly = scipy_module('scipy.signal').resample_poly samples = _planar(samples) if not samples.size: return -math.inf @@ -104,7 +106,7 @@ def __init__(self, ceiling_db, rate, channels=2, *, lookahead_seconds=.005, rele def _measure(self, final): """Extend `required` (the gain each pending sample needs on its own) as far as the filter context allows.""" - from scipy.signal import resample_poly + resample_poly = scipy_module('scipy.signal').resample_poly start, size, edge = self.required.size, self.pending.shape[1], self.CONTEXT end = size if final else size - edge if end <= start: @@ -143,7 +145,7 @@ def _release(self, target): return out def _emit(self, count): - from scipy.ndimage import minimum_filter1d + minimum_filter1d = scipy_module('scipy.ndimage').minimum_filter1d channels = self.pending.shape[0] if count <= 0: return np.zeros((channels, 0)) diff --git a/freevideo_engine/dubbing/cosyvoice3/hifigan/generator.py b/freevideo_engine/dubbing/cosyvoice3/hifigan/generator.py index 3766c84e..6aebca14 100644 --- a/freevideo_engine/dubbing/cosyvoice3/hifigan/generator.py +++ b/freevideo_engine/dubbing/cosyvoice3/hifigan/generator.py @@ -16,7 +16,6 @@ from typing import Dict, Optional, List import numpy as np -from scipy.signal import get_window import torch import torch.nn as nn import torch.nn.functional as F @@ -32,6 +31,7 @@ from freevideo_engine.dubbing.cosyvoice3.transformer.activation import Snake from freevideo_engine.dubbing.cosyvoice3.utils.common import get_padding from freevideo_engine.dubbing.cosyvoice3.utils.common import init_weights +from freevideo_engine.app_control import scipy_module """hifigan based generator implementation. @@ -471,7 +471,7 @@ def __init__( self.ups.apply(init_weights) self.conv_post.apply(init_weights) self.reflection_pad = nn.ReflectionPad1d((1, 0)) - self.stft_window = torch.from_numpy(get_window("hann", istft_params["n_fft"], fftbins=True).astype(np.float32)) + self.stft_window = torch.from_numpy(scipy_module("scipy.signal").get_window("hann", istft_params["n_fft"], fftbins=True).astype(np.float32)) self.f0_predictor = f0_predictor def remove_weight_norm(self): @@ -665,7 +665,7 @@ def __init__( self.ups.apply(init_weights) self.conv_post.apply(init_weights) self.reflection_pad = nn.ReflectionPad1d((1, 0)) - self.stft_window = torch.from_numpy(get_window("hann", istft_params["n_fft"], fftbins=True).astype(np.float32)) + self.stft_window = torch.from_numpy(scipy_module("scipy.signal").get_window("hann", istft_params["n_fft"], fftbins=True).astype(np.float32)) self.conv_pre_look_right = conv_pre_look_right self.f0_predictor = f0_predictor diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 73945fd9..6011b885 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -599,7 +599,9 @@ def _launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_re action='原因在详情里;使用已有的 ComfyUI 时,可以用原来的启动器检查。' if zh else 'Open details for the exit code and plugin errors. For an existing ComfyUI, also check its original launcher.', kind='comfy-startup') - if 'Failed step: kernels (' in summary: + # A file Windows refused to load is no GPU result: the app-control guidance below applies, and the + # code the engine adds to the message makes it recognisable in any system language. + if 'Failed step: kernels (' in summary and not re.search(APPLICATION_CONTROL, summary): detail = summary[summary.index('Failed step: kernels ('):] detail = detail.partition('\nRetained step log:')[0] if zh: diff --git a/freevideo_engine/paths.py b/freevideo_engine/paths.py index b7ce2b25..3168e532 100644 --- a/freevideo_engine/paths.py +++ b/freevideo_engine/paths.py @@ -47,6 +47,8 @@ def add_vdn(root=None): raise RuntimeError('Patched Diffusers dependency is missing. Run freevideo setup --install-packages.') if str(diffusers) not in sys.path: sys.path.insert(0, str(diffusers)) + from .app_control import tolerate_blocked_scipy + tolerate_blocked_scipy() from .fa4_guard import activate activate() return root diff --git a/freevideo_engine/probe.py b/freevideo_engine/probe.py index 7f265f1d..3a18f2ec 100644 --- a/freevideo_engine/probe.py +++ b/freevideo_engine/probe.py @@ -100,7 +100,8 @@ def main(): main() except Exception as error: traceback.print_exc() - print(json.dumps({'backend': sys.argv[1], 'status': 'error', 'error': str(error), + from .app_control import describe + print(json.dumps({'backend': sys.argv[1], 'status': 'error', 'error': describe(error), 'torch_flash_compiled': torch.backends.cuda.is_flash_attention_available(), 'torch': str(torch.__version__), 'cuda': torch.version.cuda, 'system': platform.system()}), flush=True) From e317c51e7ba578ab1edc0acbae35f5e95cc34081 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 22:09:27 +0000 Subject: [PATCH 21/43] An engine setup rebuilds a Diffusers folder that Git cannot read instead of failing every retry The pinned Diffusers source is built once and checked on later setups. A folder left without its Git data (removed by a cleaner or sync tool, lost objects) made the check fail with a bare Git exit code, and every retry failed the same way. Such a folder is now kept beside the new one under a separate name and built again. A readable folder with another tree is still never replaced, and Git that cannot start is still reported as it is. --- freevideo_engine/install.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/install.py b/freevideo_engine/install.py index ce74e48f..87bdcfb9 100644 --- a/freevideo_engine/install.py +++ b/freevideo_engine/install.py @@ -77,7 +77,19 @@ def prepare_diffusers(root, dependencies): spec = dependencies['diffusers'] target = root / dependencies['vdn']['diffusers_subdirectory'] built = False - if not io_path(target).exists(): + state = None + if io_path(target).exists(): + try: + state = network.repository_state(target, 'git', None, 'HEAD^{tree}') + except (subprocess.CalledProcessError, OSError) as error: + # Git that cannot start is a problem of this machine, not of the folder. + if not isinstance(error, subprocess.CalledProcessError) and io_path(target / '.git' / 'HEAD').exists(): + raise + # A folder Git cannot read (its .git removed by a cleaner or a sync + # tool, objects lost) can never pass the check below, so every retry + # would fail the same way. Keep it beside the new one and build again. + network.relocate(target, root / ('engine-diffusers-unreadable-' + str(time.time_ns()))) + if state is None: # Where the folder is too deep for Git to work in, build in a short real one. scratch = network.git_scratch_folder() if network.git_folder_too_deep(root / 'engine-diffusers-0') else None checkout = scratch / 'repo' if scratch else root / ('engine-diffusers-' + str(time.time_ns())) @@ -105,7 +117,7 @@ def prepare_diffusers(root, dependencies): if scratch and scratch.exists(): network.remove_tree(scratch) if not built: - tree, dirty = network.repository_state(target, 'git', None, 'HEAD^{tree}') + tree, dirty = state if tree != spec['patched_tree'] or dirty: raise RuntimeError('Existing Diffusers differs from the clean tested tree; use another VDN dependency directory.') return target From 8dfed0b2c914b4773b06a806490736773f92d9ac Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 22:22:35 +0000 Subject: [PATCH 22/43] Tutorial book: add beat-snap and system-status chapters Two chapters (33 -> 35): snapping clips to the beats of the music on Audio 2, and the system status in the top bar with Stop now. Chinese and English pictures captured from the shipped panels with offline fixtures. --- web/tutorial/beat-snap-1.webp | Bin 0 -> 25330 bytes web/tutorial/beat-snap-2.webp | Bin 0 -> 25726 bytes web/tutorial/system-status-1.webp | Bin 0 -> 9878 bytes web/tutorial/system-status-2.webp | Bin 0 -> 9836 bytes web/tutorial_book_content.js | 37 ++++++++++++++++++++++++++++++ 5 files changed, 37 insertions(+) create mode 100644 web/tutorial/beat-snap-1.webp create mode 100644 web/tutorial/beat-snap-2.webp create mode 100644 web/tutorial/system-status-1.webp create mode 100644 web/tutorial/system-status-2.webp diff --git a/web/tutorial/beat-snap-1.webp b/web/tutorial/beat-snap-1.webp new file mode 100644 index 0000000000000000000000000000000000000000..07198a580e773ddce4b2c62be25f7b2952edf352 GIT binary patch literal 25330 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 literal 0 HcmV?d00001 diff --git a/web/tutorial/beat-snap-2.webp b/web/tutorial/beat-snap-2.webp new file mode 100644 index 0000000000000000000000000000000000000000..2cc28345f3c921286a7c6677b6105f020e377c7e GIT binary patch literal 25726 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 Bp^N|k literal 0 HcmV?d00001 diff --git a/web/tutorial/system-status-2.webp b/web/tutorial/system-status-2.webp new file mode 100644 index 0000000000000000000000000000000000000000..79b8fb4b92a700ef345e48cd4bd59a75bb93d791 GIT binary patch literal 9836 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 literal 0 HcmV?d00001 diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index 6edddd2f..38c86689 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -532,6 +532,25 @@ export const TUTORIAL_CHAPTERS = [ en: ['The first run downloads the music model.', 'To replace separated music, select a clip on the music track. The button then reads "Replace music".'], }, }, + { + id: 'beat-snap', + view: 'editor', + title: {zh: '对齐音乐节拍', en: 'Cut to the beat'}, + summary: {zh: '“声音 2”轨上有音乐时,拖动片段会吸附到音乐的拍点。', en: 'With music on the "Audio 2" track, clips you drag snap to its beats.'}, + media: { + type: 'image', + src: {zh: 'beat-snap-1.webp', en: 'beat-snap-2.webp'}, + alt: {zh: '剪辑视图:正在拖动的画面片段和工具栏的“吸附”按钮已用红框标出,“声音 2”轨上显示灰色拍点线。', en: 'The Edit view, with the clip being dragged and the "Snapping" button outlined in red, and grey beat lines on the "Audio 2" track.'}, + }, + steps: { + zh: ['点“+ 声音轨”添加“声音 2”,把音乐放到这条轨上:从“已导入”拖入,或按“加配乐”一章生成。', '确认工具栏的“吸附”按钮(磁铁图标)已打开,也可按 S 键切换。', '拖动画面片段或片段的边缘,“声音 2”轨上会出现灰色的拍点线。', '片段的开头或结尾靠近拍点时会对齐到该拍点,松开鼠标后片段停在这里。'], + en: ['Choose "+ Audio track" to add "Audio 2", then put music on it: drag it in from "Imported", or make it as in the "Add music" chapter.', 'Make sure the "Snapping" button (magnet icon) in the toolbar is on. The S key turns it on and off.', 'Drag a picture clip or the edge of a clip. Grey beat lines appear on the "Audio 2" track.', 'When the start or end of the clip comes near a beat, it lines up with that beat. Release the mouse to leave it there.'], + }, + limits: { + zh: ['只有“声音 2”轨上的音乐有拍点;节奏不明显的音乐没有拍点线。', '拍点线过密时不显示,放大时间线后出现;附近有片段边缘、播放头等其他吸附点时,优先对齐它们。', '拖动时按住 Alt 可临时反转吸附。'], + en: ['Only music on the "Audio 2" track has beats. Music without a clear pulse shows no beat lines.', 'Beat lines are hidden while they are too close together; zoom in on the timeline to see them. Other snap points nearby, such as clip edges or the playhead, take priority over beats.', 'Hold Alt while dragging to reverse snapping for that drag.'], + }, + }, { id: 'voice-recording', view: 'editor', @@ -668,6 +687,24 @@ export const TUTORIAL_CHAPTERS = [ en: ['FreeVideo asks before deleting a video, and a video used in an edit cannot be deleted.', 'Cancelling an export leaves no unfinished file.'], }, }, + { + id: 'system-status', + title: {zh: '运行状态', en: 'System status'}, + summary: {zh: '在顶栏查看显存、温度和硬盘空间,必要时立即停止生成。', en: 'Watch VRAM, temperature and disk space from the top bar, and stop a generation at once when needed.'}, + media: { + type: 'image', + src: {zh: 'system-status-1.webp', en: 'system-status-2.webp'}, + alt: {zh: '顶栏的运行状态和展开的面板:运行状态和“立即停止”按钮已用红框标出。', en: 'The system status in the top bar with its panel open; the status and the "Stop now" button are outlined in red.'}, + }, + steps: { + zh: ['点顶部的“设置”,在“运行状态”下勾选“在顶栏显示运行状态”。', '顶栏出现运行状态,显示显存占用和显卡温度;顶栏放不下时只显示图标。', '点运行状态打开面板,查看显卡名称、显存、使用率、温度和硬盘剩余空间,数值每 2 秒更新一次。', '要马上停下正在进行的生成时,点“立即停止”,再在 4 秒内点“确认停止”;队列随之暂停,点“继续队列”恢复。'], + en: ['Choose "Settings" at the top, and under "System status" turn on "Show system status in the top bar".', 'The status appears in the top bar, with the VRAM in use and the graphics card temperature. When the top bar is short of room, only its icon is shown.', 'Choose the status to open its panel: the graphics card name, VRAM, utilization, temperature and free disk space, updated every 2 seconds.', 'To stop the running generation at once, choose "Stop now", then "Confirm stop" within 4 seconds. The queue is paused; choose "Resume queue" to continue.'], + }, + limits: { + zh: ['显存达到 95% 或温度达到 85 °C 时,数值以红色显示。', '“立即停止”只在有生成正在进行时可用。', '窗口很窄时,顶栏不显示运行状态。'], + en: ['VRAM at 95% or more, or a temperature of 85 °C or more, is shown in red.', '"Stop now" is available only while a generation is running.', 'In a very narrow window, the top bar does not show the status.'], + }, + }, { id: 'troubleshoot', title: {zh: '出错了怎么办', en: 'When something goes wrong'}, From 62ec115c0f615741fa12050776b5d33b4976ffd3 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Thu, 12 Nov 2026 23:01:22 +0000 Subject: [PATCH 23/43] H3 Image takes up to 16 reference pictures, refuses ones that cannot fit before starting, and... H3 Image takes up to 16 reference pictures, refuses ones that cannot fit before starting, and runs long sequences in row slices References are sized, labelled and encoded as the released edit pipeline does and enter the sequence as fixed conditioning rows. Long sequences run their row-wise work in slices of 16384 rows, which gives identical results while avoiding fragmented card memory. The planner counts reference rows from sliced measurements, and requests whose streamed-only floor cannot fit the card are refused with the reference-memory message before the text encoder runs. --- freevideo_engine/encode.py | 4 +- freevideo_engine/encode_worker.py | 37 ++++- freevideo_engine/image_gen/h3_dit.py | 82 ++++++++-- freevideo_engine/image_gen/h3_pipeline.py | 170 +++++++++++++++++++-- freevideo_engine/image_gen/h3_vae.py | 124 +++++++++++++++ freevideo_engine/image_gen/image_policy.py | 16 +- freevideo_engine/image_gen/service.py | 4 +- 7 files changed, 409 insertions(+), 28 deletions(-) diff --git a/freevideo_engine/encode.py b/freevideo_engine/encode.py index de9f8ad7..dd125c08 100644 --- a/freevideo_engine/encode.py +++ b/freevideo_engine/encode.py @@ -36,7 +36,9 @@ def interrupted(signum, frame): 'comfy_root': str(args.comfy_root or comfy_root()), 'model_paths': str(args.model_paths.resolve()) if args.model_paths else None, 'output': str(destination), 'gpu_budget_gb': policy.gpu_budget_bytes / 1e9, - 'metrics': str(destination.with_suffix('.encoding.json'))}) + '\n', encoding='utf-8') + 'metrics': str(destination.with_suffix('.encoding.json')), + **({'h3_image_references': [str(Path(p).resolve()) for p in args.h3_image_references]} + if getattr(args, 'h3_image_references', None) else {})}) + '\n', encoding='utf-8') env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1]), PYTORCH_ALLOC_CONF=policy.allocator_config, PYTORCH_CUDA_ALLOC_CONF=policy.allocator_config, diff --git a/freevideo_engine/encode_worker.py b/freevideo_engine/encode_worker.py index e298626f..0ee1bfbd 100644 --- a/freevideo_engine/encode_worker.py +++ b/freevideo_engine/encode_worker.py @@ -198,6 +198,36 @@ def stop_model_read_ahead(trace): trace.data['model_read_ahead'] = reader.close() +def h3_image_presentation(clip, prompt, paths): + """Tokens of an H3 Image edit: per reference ``Picture N: `` and its vision block, then the prompt. + + The released H3 Image pipeline labels its references without the angle brackets the + video model's presentation uses (the prompt still refers to them as ````); + the vision block, its tags and the prompt are the same. The images arrive already + sized for the vision tower (short edge 1024, sides multiples of 32). + """ + import numpy as np + import torch + from PIL import Image + from comfy.text_encoders.minimax import VISION_START, VISION_END + tokenizer = clip.tokenizer + entries = [] + + def add_text(text): + batches = tokenizer.qwen3vl_32b.tokenize_with_weights(text, return_word_ids=False, disable_weights=True) + if len(batches) != 1: + raise ValueError('MiniMax H3 text segment exceeds the supported prompt length.') + entries.extend(batches[0]) + + for index, path in enumerate(paths): + with Image.open(path) as image: + pixels = torch.from_numpy(np.array(image.convert('RGB'))).float().div_(255)[None] + add_text('Picture %d: ' % (index + 1)) + entries.extend([(VISION_START, 1.0), (tokenizer._vision_entry(pixels), 1.0), (VISION_END, 1.0)]) + add_text(prompt) + return {'qwen3vl_32b': [entries]} + + def encode(args, request, resident=None, diagnostics=None): from .encoder_diagnostics import EncoderTrace trace = diagnostics or EncoderTrace(request, resident=resident is not None) @@ -392,7 +422,10 @@ def phase(name, **metrics): import numpy as np vision_kwargs['images'] = [torch.from_numpy(np.array(image)).float().div_(255)[None] for image in images] print(json.dumps({'event': 'encoder_tokenize_start'}), flush=True) - tokens = clip.tokenize(prompt, **vision_kwargs) + if request.get('h3_image_references'): + tokens = h3_image_presentation(clip, prompt, request['h3_image_references']) + else: + tokens = clip.tokenize(prompt, **vision_kwargs) from .encoder_memory import token_counts loading['token_summary'] = token_counts(tokens) tokenize_seconds = time.perf_counter() - encode_started @@ -552,7 +585,7 @@ def attempt(index, failure): reader.close() del timed_load, native_load, attempt del tokens - task = 'fl2va' if images is not None else task + task = 'fl2va' if images is not None else 'ref2va' if request.get('h3_image_references') else task value = to_cache(encoded, prompt, task=task) value['task'] = task if early_reference is not None: diff --git a/freevideo_engine/image_gen/h3_dit.py b/freevideo_engine/image_gen/h3_dit.py index 28b399c8..b07f43b0 100644 --- a/freevideo_engine/image_gen/h3_dit.py +++ b/freevideo_engine/image_gen/h3_dit.py @@ -18,12 +18,33 @@ REFERENCE_TIME = 0.999 +# Sequences longer than this (references make them several times the image's own rows) run their row-wise work +# in slices of this many rows. A whole-sequence MLP of 48k rows holds 5 GiB of temporaries of four sizes beside the +# streamed weights, and the card ran out at 9 of 12 GiB with 2.5 GiB held free in pieces. Every slice is the same +# arithmetic on the same rows (int8 activations carry one scale per row), so the result does not change. +ROW_SLICE = 16384 + + def rms(x, weight, eps=EPS): + if x.shape[0] > ROW_SLICE: + return by_rows(x, lambda part: rms(part, weight, eps)) value = x.float() value = value * torch.rsqrt(value.pow(2).mean(-1, keepdim=True) + eps) return (value * weight.float()).to(x.dtype) +def by_rows(x, fn): + """``fn`` over ``ROW_SLICE``-row slices of ``x``, written into one output.""" + out = None + for start in range(0, x.shape[0], ROW_SLICE): + part = fn(x[start:start + ROW_SLICE]) + if out is None: + out = part.new_empty((x.shape[0],) + tuple(part.shape[1:])) + out[start:start + part.shape[0]] = part + del part + return out + + class Attention(nn.Module): def __init__(self): super().__init__() @@ -35,7 +56,7 @@ def __init__(self): self.q_norm.register_parameter('weight', nn.Parameter(torch.empty(HEAD_DIM), requires_grad=False)) self.k_norm.register_parameter('weight', nn.Parameter(torch.empty(HEAD_DIM), requires_grad=False)) - def forward(self, x, rotation=None): + def project(self, x, rotation=None): rows = x.shape[0] q, k, v = self.qkv_proj(x).split(HEADS * HEAD_DIM, dim=-1) q = rms(q.view(rows, HEADS, HEAD_DIM), self.q_norm.weight) @@ -43,10 +64,25 @@ def forward(self, x, rotation=None): if rotation is not None: cos, sin = rotation q, k = rotate(q, cos, sin), rotate(k, cos, sin) - v = v.view(rows, HEADS, HEAD_DIM) + return q, k, v.view(rows, HEADS, HEAD_DIM) + + def forward(self, x, rotation=None): + rows = x.shape[0] + if rows > ROW_SLICE: + q, k, v = (x.new_empty(rows, HEADS, HEAD_DIM) for _ in range(3)) + for start in range(0, rows, ROW_SLICE): + part = slice(start, start + ROW_SLICE) + turn = None if rotation is None else (rotation[0][part], rotation[1][part]) + q[part], k[part], v[part] = self.project(x[part], turn) + else: + q, k, v = self.project(x, rotation) out = F.scaled_dot_product_attention(q.transpose(0, 1)[None], k.transpose(0, 1)[None], v.transpose(0, 1)[None]) - return self.out_proj(out[0].transpose(0, 1).reshape(rows, HEADS * HEAD_DIM)) + del q, k, v + out = out[0].transpose(0, 1) + if rows > ROW_SLICE: + return by_rows(out, lambda part: self.out_proj(part.reshape(part.shape[0], HEADS * HEAD_DIM))) + return self.out_proj(out.reshape(rows, HEADS * HEAD_DIM)) def rotate(x, cos, sin): @@ -64,6 +100,8 @@ def __init__(self): self.fc2 = nn.Linear(FFN, HIDDEN, bias=False) def forward(self, x): + if x.shape[0] > ROW_SLICE: + return by_rows(x, self.forward) gate, up = self.fc1(x).chunk(2, dim=-1) return self.fc2(F.silu(gate) * up) @@ -269,26 +307,50 @@ def unpatchify(rows, height, width, channels=24): return rows.reshape(height // 2, width // 2, channels, 2, 2).permute(2, 0, 3, 1, 4).reshape(channels, height, width) +def reference_rows(references, generator, device, noise_device=None): + """The rows of normalised reference latents ([24, h, w] each), noised to ``REFERENCE_TIME`` with one noise + tensor per reference drawn from ``generator`` in order, as the released edit pipeline does after the target + noise. The level is a float32 tensor, so ``1 - t`` is evaluated in float32 as the released model mixes it.""" + level = torch.tensor(REFERENCE_TIME, dtype=torch.float32, device=device) + rows = [] + for latent in references: + latent = latent.to(device, torch.float32) + noise = torch.randn(latent.shape, generator=generator, device=noise_device or device, + dtype=torch.float32).to(device) + rows.append(patchify(level * latent + (1.0 - level) * noise)) + return torch.cat(rows) if rows else None + + @torch.no_grad() def sample(dit, features, tags, latent_h, latent_w, generator, device='cuda', dtype=torch.bfloat16, blocks=None, - noise_device=None, step_callback=None, preview_callback=None): - """Text-to-image: six velocity-Euler steps from noise; returns [24, H, W] float32 latents. + noise_device=None, step_callback=None, preview_callback=None, references=()): + """Six velocity-Euler steps from noise; returns [24, H, W] float32 latents. + ``references`` are normalised reference latents ([24, h, w], each at its own size, rows of 2x2 patches). Their + rows go in front of the generated ones, held at ``REFERENCE_TIME`` for every step; only the generated rows move. ``preview_callback(step, total, rows, velocity, sigma, latent_h, latent_w)`` runs after each step's velocity, before - the update; it only reads the two tensors (the clean prediction is ``rows + sigma * velocity``).""" + the update, with the generated rows only; it only reads the two tensors (the clean prediction is + ``rows + sigma * velocity``).""" text = dit.embed_text(features.to(device)) noise = torch.randn((1, 24, 1, latent_h, latent_w), generator=generator, device=noise_device or device, dtype=torch.float32).to(device) rows = patchify(noise[0, :, 0]) - positions = positions_for(features.shape[0], [], latent_h, latent_w).to(device) - segments = segments_for([int(t) for t in tags], 0, rows.shape[0]) + held = reference_rows(references, generator, device, noise_device) + count = 0 if held is None else held.shape[0] + grids = [tuple(latent.shape[-2:]) for latent in references] + positions = positions_for(features.shape[0], grids, latent_h, latent_w).to(device) + segments = segments_for([int(t) for t in tags], count, rows.shape[0]) steps = torch.tensor([1.0 - s for s in SIGMAS[:-1]], dtype=torch.float32) sigmas = torch.cat([1.0 - steps, torch.zeros(1)]) for i in range(len(steps)): if step_callback is not None: step_callback(i, len(steps)) - times = steps[i:i + 1].to(device) - velocity = dit(text, rows, positions, segments, times, i, blocks=blocks) + times = steps[i:i + 1] if held is None else torch.unique( + torch.tensor([float(steps[i]), REFERENCE_TIME], dtype=torch.float32), sorted=True) + if times.numel() != (1 if held is None else 2) or float(times[0]) != float(steps[i]): + raise ValueError('a step time at or above the reference time breaks the row layout') + velocity = dit(text, rows if held is None else torch.cat([held, rows]), positions, segments, times.to(device), + i, blocks=blocks)[count:] if preview_callback is not None: preview_callback(i + 1, len(steps), rows, velocity, float(sigmas[i]), latent_h, latent_w) rows = rows + (sigmas[i] - sigmas[i + 1]).to(device) * velocity diff --git a/freevideo_engine/image_gen/h3_pipeline.py b/freevideo_engine/image_gen/h3_pipeline.py index 0d3b5ab4..e5ac0cd2 100644 --- a/freevideo_engine/image_gen/h3_pipeline.py +++ b/freevideo_engine/image_gen/h3_pipeline.py @@ -17,7 +17,14 @@ from . import h3_dit from .h3_weights import load_h3_dit -MAX_REFERENCES = 0 # reference images are not supported by this engine yet +MAX_REFERENCES = 16 +# The released edit pipeline's reference sizes: the VAE copy keeps its size while the short edge is within +# [1024, 2048] and is scaled to the nearer bound otherwise; the vision copy has a short edge of exactly 1024. A +# resized side rounds to 32. References are never resized to the output size. +REFERENCE_SHORT_EDGE = (1024, 2048) +VISION_SHORT_EDGE = 1024 +SIDE_MULTIPLE = 32 +REFERENCE_CACHE_RECIPE = 'h3-image-reference-moments-v1' def free_host_memory(): @@ -33,7 +40,69 @@ class Cancelled(Exception): pass -def _encode(prompt, *, encoder, comfy_root, comfy_python, model_paths, vram_gib, ram_gib): +def bound_short_edge(width, height, low, high, multiple=SIDE_MULTIPLE): + """(width, height) a reference is resized to for one consumer (the released pipeline's rule).""" + short = min(width, height) + if low <= short <= high: + return width, height + scale = (low if short < low else high) / short + return (max(multiple, round(width * scale / multiple) * multiple), + max(multiple, round(height * scale / multiple) * multiple)) + + +def reference_copies(references): + """[(vae copy, vision copy)] of PIL references: read upright, RGB, each copy sized for its consumer.""" + from PIL import Image, ImageOps + copies = [] + for image in references: + image = ImageOps.exif_transpose(image).convert('RGB') + if min(image.size) < SIDE_MULTIPLE: + raise ValueError(f'A reference image needs at least {SIDE_MULTIPLE} pixels on each side, got {image.size}.') + + def fit(low, high): + size = bound_short_edge(*image.size, low, high) + return image if image.size == size else image.resize(size, Image.LANCZOS) + copies.append((fit(*REFERENCE_SHORT_EDGE), fit(VISION_SHORT_EDGE, VISION_SHORT_EDGE))) + return copies + + +def reference_sequence_rows(copies): + """Rows the references add to the transformer's sequence: their latent patches and their vision tokens.""" + rows = 0 + for index, (vae_copy, vision_copy) in enumerate(copies): + width, height = vae_copy.size + rows += (height // 16 // 2) * (width // 16 // 2) + width, height = vision_copy.size + # The vision block with its two markers, and the label ("Picture 1: " is five tokens, "Picture 10: " six). + rows += round(height / 32) * round(width / 32) + 2 + 4 + len(str(index + 1)) + return rows + + +# What the streamed-only floor must leave free for the request to be tried at all. Under an 8 GiB cap 16 photos +# (floor 7.99 GiB) ran out in the first step with 0.84 GiB held in pieces, while 4 large references (floor 6.72) +# finished; half the planner's smallest margin separates the two. +REFUSE_ALLOWANCE = 0.75 * 2**30 + + +class ReferencesTooLarge(MemoryError): + """The references cannot fit on this card even with every transformer block streamed.""" + + +def card_budget(vram_gib): + """Bytes of the card this request may use: the caller's limit, else the whole card.""" + total = torch.cuda.get_device_properties(0).total_memory + return int(min(vram_gib * 2**30, total)) if vram_gib else total + + +def denoise_floor_bytes(copies, width, height): + """The denoising stage's peak with nothing resident: the least any plan for these references needs.""" + from .image_policy import ImageShape, stage_peaks + canvas_h, canvas_w = h3_dit.canvas_size(height, width) + return stage_peaks(ImageShape(), 0, 0, width=canvas_w, height=canvas_h, + sequence_rows=reference_sequence_rows(copies))['denoise'] + + +def _encode(prompt, *, encoder, comfy_root, comfy_python, model_paths, vram_gib, ram_gib, vision_images=()): """Run the shared text encoder in its own process and return the saved conditioning.""" from .. import encode as encode_command import sys @@ -41,18 +110,69 @@ def _encode(prompt, *, encoder, comfy_root, comfy_python, model_paths, vram_gib, prompt_file = Path(directory) / 'prompt.txt' prompt_file.write_text(prompt, encoding='utf-8') out = Path(directory) / 'cond.pt' + pictures = [] + for index, image in enumerate(vision_images): + pictures.append(Path(directory) / f'picture-{index + 1}.png') + image.save(pictures[-1], format='PNG', compress_level=1) encode_command.run(argparse.Namespace( prompt_file=prompt_file, out=out, encoder=encoder, comfy_root=Path(comfy_root) if comfy_root else None, model_paths=Path(model_paths) if model_paths else None, comfy_python=comfy_python or sys.executable, vram_gib=vram_gib, ram_gib=ram_gib, attention='auto', - gpu_reserve_gib=None, ram_reserve_gib=None)) + gpu_reserve_gib=None, ram_reserve_gib=None, h3_image_references=pictures)) return torch.load(out, map_location='cpu', weights_only=False) +def reference_moments(copies, vae_path, device, *, cache=True, cache_root=None, stats=None): + """Posterior moments ([1, 48, h, w] float32 on the CPU) of each reference's VAE copy, read from or kept in the + input cache the video references use; the encoder loads only when one is missing.""" + import hashlib + import numpy as np + from .h3_vae import H3ImageEncoder, PIXEL_MEAN, PIXEL_STD + store = None + if cache: + try: + from ..media_encoding import _latent_cache + store = _latent_cache(cache_root, Path(vae_path).parent.parent, device, recipe=REFERENCE_CACHE_RECIPE) + except Exception: + store = None + out, keys, encoder, hits = [], [], None, 0 + work = Path(tempfile.mkdtemp(prefix='freevideo-h3ref-')) + try: + for vae_copy, _ in copies: + pixels = np.asarray(vae_copy, dtype=np.uint8) + pixels = pixels[:pixels.shape[0] // 16 * 16, :pixels.shape[1] // 16 * 16] + key = 'h3-image-reference:%s:%dx%d' % (hashlib.sha256(np.ascontiguousarray(pixels).data).hexdigest(), + pixels.shape[1], pixels.shape[0]) + moments = None + if store is not None: + from ..media_encoding import _cached_latent + moments = _cached_latent(store, key, work) + if moments is None: + if encoder is None: + encoder = H3ImageEncoder.load(str(vae_path), device) + tensor = torch.from_numpy(np.ascontiguousarray(pixels)).to(device).permute(2, 0, 1)[None].float() + mean = torch.tensor(PIXEL_MEAN, device=device).view(1, 3, 1, 1) + std = torch.tensor(PIXEL_STD, device=device).view(1, 3, 1, 1) + moments = encoder.moments((tensor / 255.0 - mean) / std).cpu() + if store is not None: + from ..media_encoding import _remember_latent + _remember_latent(store, key, moments, work) + else: + hits += 1 + out.append(moments) + finally: + import shutil + shutil.rmtree(work, ignore_errors=True) + del encoder + if stats is not None: + stats['reference_cache_hits'] = hits + return out + + def generate(prompt, width, height, seed, references=None, progress_cb=None, cancel=None, *, dit_path, vae_path, encoder='qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors', comfy_root=None, comfy_python=None, model_paths=None, vram_gib=None, ram_gib=None, resident=None, cpu_noise=False, stats=None, mapped=True, - preview=None, enter=None): + preview=None, enter=None, reference_cache=True, reference_cache_root=None): """Return a PIL image for the prompt. ``progress_cb(stage, done, total)`` is called with stage in encode, denoise, decode; @@ -64,8 +184,9 @@ def generate(prompt, width, height, seed, references=None, progress_cb=None, can ``enter(stage)`` names the stage a failure belongs to without moving the progress: the DiT loads after the text, so a load error would otherwise be filed under the text stage. """ - if references: - raise ValueError('H3 Image does not take reference images yet') + references = list(references or []) + if len(references) > MAX_REFERENCES: + raise ValueError('too_many_images') stats = stats if stats is not None else {} report = progress_cb or (lambda *a: None) @@ -75,10 +196,35 @@ def check(): check() report('encode', 0, 1) + copies = reference_copies(references) + if copies and denoise_floor_bytes(copies, width, height) + REFUSE_ALLOWANCE > card_budget(vram_gib): + # Refused before the text encoder starts: otherwise the request spends a minute or more there and runs out + # in the first step. Filed under the reference stage, where the page asks for fewer references. + if enter is not None: + enter('encode') + raise ReferencesTooLarge('the references need more memory than this card has, even with every block streamed') tick = time.perf_counter() cond = _encode(prompt, encoder=encoder, comfy_root=comfy_root, model_paths=model_paths, comfy_python=comfy_python, - vram_gib=vram_gib, ram_gib=ram_gib) + vram_gib=vram_gib, ram_gib=ram_gib, vision_images=[vision for _, vision in copies]) stats['encode_s'] = round(time.perf_counter() - tick, 2) + check() + moments = [] + if copies: + # Encoded before the DiT loads, so the card holds only the encoder and one batch of tiles. + if enter is not None: + enter('encode') + tick = time.perf_counter() + if vram_gib: + total = torch.cuda.get_device_properties(0).total_memory + torch.cuda.set_per_process_memory_fraction(min(1.0, vram_gib * 2**30 / total)) + torch.cuda.reset_peak_memory_stats() + moments = reference_moments(copies, vae_path, torch.device('cuda'), cache=reference_cache, + cache_root=reference_cache_root, stats=stats) + free_host_memory() + stats.update(reference_encode_s=round(time.perf_counter() - tick, 2), + reference_encode_peak_gib=round(torch.cuda.max_memory_allocated() / 2**30, 2), + reference_sizes=[list(vae.size) for vae, _ in copies], + text_tokens=int(cond['prompt_embeds'].shape[-2])) report('encode', 1, 1) check() @@ -89,7 +235,7 @@ def check(): torch.set_grad_enabled(False) device = torch.device('cuda') total = torch.cuda.get_device_properties(0).total_memory - budget = int(min(vram_gib * 2**30, total)) if vram_gib else total + budget = card_budget(vram_gib) if vram_gib: torch.cuda.set_per_process_memory_fraction(min(1.0, budget / total)) canvas_h, canvas_w = h3_dit.canvas_size(height, width) @@ -104,12 +250,16 @@ def check(): stack = StreamedStack(model.blocks, name='h3', device=device) shape = ImageShape.measure(list(model.blocks), None) shape = ImageShape(**{**shape.__dict__, 'steps': 6}) - plan = plan_image(gpu_budget_bytes=budget, width=canvas_w, height=canvas_h, shape=shape) + plan = plan_image(gpu_budget_bytes=budget, width=canvas_w, height=canvas_h, shape=shape, + sequence_rows=reference_sequence_rows(copies)) stack.set_resident(plan.dit_resident_layers if resident is None else resident) stats.update(load_s=round(time.perf_counter() - tick, 2), canvas=[canvas_h, canvas_w], resident=plan.dit_resident_layers if resident is None else resident, planned_peak_gib=round(plan.peak_allocated_bytes / 2**30, 2), host_mode=plan.host_mode) generator = torch.Generator('cpu' if cpu_noise else device).manual_seed(seed) + # The released order: each reference's posterior sample, then the image's noise, then each reference's noise. + from .h3_vae import reference_latents + held = [reference_latents(m.to(device), generator, 'cpu' if cpu_noise else None) for m in moments] def step(i, n): check() @@ -119,7 +269,7 @@ def step(i, n): try: latents = h3_dit.sample(model, cond['prompt_embeds'], cond['text_token_tags'], canvas_h // 16, canvas_w // 16, generator, noise_device='cpu' if cpu_noise else None, step_callback=step, - preview_callback=preview) + preview_callback=preview, references=held) torch.cuda.synchronize() stats['sample_s'] = round(time.perf_counter() - tick, 2) stats['denoise_peak_gib'] = round(torch.cuda.max_memory_allocated() / 2**30, 2) diff --git a/freevideo_engine/image_gen/h3_vae.py b/freevideo_engine/image_gen/h3_vae.py index 986aec53..cbd0c10c 100644 --- a/freevideo_engine/image_gen/h3_vae.py +++ b/freevideo_engine/image_gen/h3_vae.py @@ -268,6 +268,130 @@ def _decode_one(self, z, tile, overlap, max_batch): return output +class H3ImageEncoder: + """The H3 Image VAE encoder, for reference images. + + The released encoder is a causal 3D CNN run on one frame. Every causal convolution + pads two zero frames in front, so on a single frame only the last temporal tap of + each kernel meets data, and the temporal strides leave one frame: the encoder is + exactly a 2D CNN with those taps. The reference pipeline keeps it in float32 and + encodes in 256 px tiles overlapping by at least 64 px, blending the moments linearly + in latent space; so does this one. Its weights are the encoder half of the same + Comfy file the decoder reads (identical to the video VAE's encoder). + """ + + def __init__(self, tensors, device, dtype=torch.float32): + self.device = torch.device(device) + self.dtype = dtype + + def conv(key): + weight = tensors[key + '.weight'] + weight = weight[:, :, -1] if weight.ndim == 5 else weight + return weight.to(self.device, dtype).contiguous(), tensors[key + '.bias'].to(self.device, dtype) + + def norm(key): + return tensors[key + '.weight'].to(self.device, dtype), tensors[key + '.bias'].to(self.device, dtype) + + levels = 1 + max(int(key.split('.')[2]) for key in tensors if key.startswith('encoder.down.')) + self.conv_in = conv('encoder.conv_in') + self.levels = [] + for level in range(levels): + blocks, index = [], 0 + while f'encoder.down.{level}.block.{index}.conv1.weight' in tensors: + prefix = f'encoder.down.{level}.block.{index}.' + blocks.append(dict(norm1=norm(prefix + 'norm1'), conv1=conv(prefix + 'conv1'), + norm2=norm(prefix + 'norm2'), conv2=conv(prefix + 'conv2'), + shortcut=conv(prefix + 'nin_shortcut') + if prefix + 'nin_shortcut.weight' in tensors else None)) + index += 1 + key = f'encoder.down.{level}.downsample.conv' + # Every level that downsamples halves both sides (the released 2, 2, 2, 2, 1, 1). + self.levels.append((blocks, conv(key) if key + '.weight' in tensors else None)) + self.norm_out = norm('encoder.norm_out') + self.conv_out = conv('encoder.conv_out') + self.quant = conv('quant_conv') + + @classmethod + def load(cls, path, device='cuda', dtype=torch.float32): + """Read the encoder and quantisation tensors of the Comfy fp16 VAE file.""" + from safetensors import safe_open + tensors = {} + with safe_open(str(path), framework='pt', device='cpu') as handle: + for key in handle.keys(): + if key.startswith('encoder.') or key.startswith('quant_conv.'): + tensors[key] = handle.get_tensor(key) + return cls(tensors, device, dtype) + + @staticmethod + def _conv(x, weight_bias, stride=1, pad=1): + if pad: + x = F.pad(x, (pad,) * 4, mode='reflect') + return F.conv2d(x, *weight_bias, stride=stride) + + @staticmethod + def _norm(x, weight_bias): + return F.silu(F.group_norm(x, 32, *weight_bias, eps=1e-6)) + + def encode_tiles(self, pixels): + """(n, 3, H, W) normalised pixels, H and W multiples of 16 -> (n, 48, H/16, W/16) moments.""" + x = self._conv(pixels.to(self.device, self.dtype), self.conv_in) + for blocks, down in self.levels: + for block in blocks: + h = self._conv(self._norm(x, block['norm1']), block['conv1']) + h = self._conv(self._norm(h, block['norm2']), block['conv2']) + x = (self._conv(x, block['shortcut'], pad=0) if block['shortcut'] is not None else x) + h + if down is not None: + x = self._conv(F.pad(x, (0, 1, 0, 1), mode='reflect'), down, stride=2, pad=0) + x = self._conv(self._norm(x, self.norm_out), self.conv_out) + return self._conv(x, self.quant, pad=0) + + @torch.inference_mode() + def moments(self, pixels, tile=TILE, overlap=OVERLAP, max_batch=4): + """(1, 3, H, W) ImageNet-normalised pixels -> (1, 48, H/16, W/16) float32 posterior moments. + + Convolutions run in full float32: with TF32 the 2D form drifts up to 0.7 from the exact moments (the + released 3D form, about 0.1); without it both agree to 5e-4. The setting is restored afterwards.""" + height, width = pixels.shape[-2:] + if height % SPATIAL_RATIO or width % SPATIAL_RATIO: + raise ValueError('reference pixels must be cropped to multiples of 16') + previous = torch.backends.cudnn.allow_tf32 + torch.backends.cudnn.allow_tf32 = False + try: + return self._moments(pixels, tile, overlap, max_batch) + finally: + torch.backends.cudnn.allow_tf32 = previous + + def _moments(self, pixels, tile, overlap, max_batch): + height, width = pixels.shape[-2:] + yi, yl, yo = split_tiles(height, tile, overlap) + xi, xl, xo = split_tiles(width, tile, overlap) + y_overlaps = [o // SPATIAL_RATIO for o in yo] + x_overlaps = [o // SPATIAL_RATIO for o in xo] + pieces, above = [], None + for i, (y, h) in enumerate(zip(yi, yl)): + tiles = [pixels[..., y:y + h, x:x + w] for x, w in zip(xi, xl)] + row = [] + for start in range(0, len(tiles), max_batch): + row.extend(self.encode_tiles(torch.cat(tiles[start:start + max_batch])).float().split(1)) + below = y_overlaps[i] if i < len(y_overlaps) else 0 + pieces.append(stitch_row(row, above, y_overlaps[i - 1] if i else 0, below, x_overlaps)) + above = row + return torch.cat(pieces, dim=-2) + + +def reference_latents(moments, generator, noise_device=None): + """Posterior sample of a reference as the released recipe takes it: drawn from the request's generator, + rounded to float16, cropped to whole 2x2 patches and normalised. (1, 48, h, w) -> (24, h', w').""" + mean, logvar = moments.float().chunk(2, dim=1) + std = torch.exp(0.5 * logvar.clamp(-30.0, 20.0)) + noise = torch.randn(mean.shape, generator=generator, device=noise_device or mean.device, dtype=torch.float32) + latents = (mean + std * noise.to(mean.device)).to(torch.float16).float() + latents = latents[..., :latents.shape[-2] // 2 * 2, :latents.shape[-1] // 2 * 2] + shift = torch.tensor(LATENTS_MEAN, device=latents.device).view(1, -1, 1, 1) + scale = torch.tensor(LATENTS_STD, device=latents.device).view(1, -1, 1, 1) + return ((latents - shift) / scale)[0] + + def to_unit(pixels): """Model output to [0, 1] pixels: undo the ImageNet normalisation and clamp.""" mean = torch.tensor(PIXEL_MEAN, device=pixels.device).view(1, 3, 1, 1) diff --git a/freevideo_engine/image_gen/image_policy.py b/freevideo_engine/image_gen/image_policy.py index 7459375c..fb9cb3c2 100644 --- a/freevideo_engine/image_gen/image_policy.py +++ b/freevideo_engine/image_gen/image_policy.py @@ -56,6 +56,13 @@ def size(layer): # 4.72 GiB at 16.8 MP where the line gives 10.4), 0.147 GiB per megapixel; each # resident block adds its own size. Continued from the line's value at 4.2 MP. _DENOISE_FIT_MP, _DENOISE_LARGE_PER_MP = 4.2, 0.147 * GiB +# Rows beside the image's own (H3 Image references: latent patches and vision tokens). A short sequence pays what +# the large-canvas line does per 32x32 patch; past the transformer's row slice (h3_dit.ROW_SLICE) the row-wise work +# runs in slices and only attention and the residual stream grow with the sequence. Streamed-only peaks, int8 H3 +# Image at 4 MP, RTX PRO 6000 under a card cap: 3.75 GiB at 5.8k reference rows, 7.46 at 43.6k, 7.64 at 46.2k, +# 11.35 at 103k; both lines sit about 0.5 GiB above every one of them, as the image's own line does. +_SEQUENCE_ROW = _DENOISE_LARGE_PER_MP * 32 * 32 / 1e6 +_SLICED_BASE, _SLICED_ROW = 1.77 * GiB, 0.065 * GiB / 1000 _DECODE_BASE, _DECODE_PER_MP = 2.10 * GiB, 0.15 * GiB # Decoding the whole image at once instead of in tiles. A single frame keeps no # causal cache: the whole-image decoder workspace is 3.49 GiB per megapixel @@ -125,10 +132,12 @@ def to_dict(self): def stage_peaks(shape, dit_resident, te_resident, *, width, height, reference_pixels=0, vae_tiling=True, - vae_tile_size=SMALL_VAE_TILE, dit_kept=None): + vae_tile_size=SMALL_VAE_TILE, dit_kept=None, sequence_rows=0): """Largest allocation of each stage for a given residency (bytes). ``dit_kept`` blocks stay on the card outside denoising (default: all of them). + ``sequence_rows`` are rows the transformer runs beside the image's own (H3 Image references: their latent + patches and vision tokens); what they cost the denoising stage is described at ``_SEQUENCE_ROW``. """ mp = width * height / 1e6 ref_mp = reference_pixels / 1e6 @@ -137,6 +146,7 @@ def stage_peaks(shape, dit_resident, te_resident, *, width, height, reference_pi encode = _ENCODE_BASE + resident denoise = (_DENOISE_BASE + _DENOISE_PER_MP * min(mp, _DENOISE_FIT_MP) + _DENOISE_LARGE_PER_MP * max(0.0, mp - _DENOISE_FIT_MP) + + min(_SEQUENCE_ROW * sequence_rows, _SLICED_BASE + _SLICED_ROW * sequence_rows) + resident + (dit_resident - kept) * shape.dit_block_bytes) decode = _DECODE_BASE + _DECODE_PER_MP * mp + resident if not vae_tiling: @@ -154,7 +164,7 @@ def stage_peaks(shape, dit_resident, te_resident, *, width, height, reference_pi def plan_image(*, gpu_budget_bytes, host_available_bytes=None, width=1024, height=1024, - reference_pixels=0, shape=None, link_bytes_per_second=None): + reference_pixels=0, shape=None, link_bytes_per_second=None, sequence_rows=0): """Choose the decoding and the residency for one request. The decoding comes first: whole-image decoding is several times faster than @@ -171,7 +181,7 @@ def plan_image(*, gpu_budget_bytes, host_available_bytes=None, width=1024, heigh margin = int(max(MIN_MARGIN, MARGIN_FRACTION * gpu_budget_bytes)) limit = int(gpu_budget_bytes) - margin reasons = [] - kwargs = dict(width=width, height=height, reference_pixels=reference_pixels) + kwargs = dict(width=width, height=height, reference_pixels=reference_pixels, sequence_rows=sequence_rows) def peaks(kd, kt, tiling, tile_size, kept=None): return stage_peaks(shape, kd, kt, vae_tiling=tiling, vae_tile_size=tile_size, dit_kept=kept, **kwargs) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 35678806..bdff27f1 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -26,8 +26,8 @@ BODY_LIMIT = 64 * 1024 CANCEL_GRACE = 5. MAX_IMAGES = 4 -# Reference pictures H3 Image really takes; 0 until the engine passes them on (it then takes 16, as the model does). -H3_MAX_IMAGES = 0 +# Reference pictures H3 Image takes, each one labelled "Picture N" in upload order. +H3_MAX_IMAGES = 16 H3_MAX_AREA = image_model.H3_AREAS['high'] ** 2 H3_MAX_SIDE = 8192 H3_GROUP = 'h3-image:' + image_pack.default_tier() From 5edeea1e2a6559dcf18660b51de06d1c492e9270 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 01:06:22 +0000 Subject: [PATCH 24/43] A refused whole-sequence buffer no longer takes another head-group retry once the group is two... A refused whole-sequence buffer no longer takes another head-group retry once the group is two heads or fewer --- freevideo_engine/adaptive.py | 38 +++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index ff4a2b7b..8a39d365 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -424,17 +424,40 @@ def nonlocal_exhausted(failure): COMPUTE_DEFAULTS = dict(window_batch=1, head_parallelism=1) -def compute_recovery(engine): +# One activation of the whole sequence is rows x 5376 channels x 2 bytes (bf16). +# A refused allocation of at least the video rows' worth does not shrink with +# the head group; the group only sizes the per-head workspaces. +SEQUENCE_ROW_BYTES = 5376 * 2 +HEAD_GROUP_KEYS = ('head_chunk', 'head_parallelism') + + +def sequence_sized_failure(failure, canvas): + """The refused allocation is at least one whole-sequence activation.""" + rows = (canvas or {}).get('video_tokens') + if type(rows) is not int or rows <= 0: + return False + exceptions = failure.get('exception') + for item in exceptions if isinstance(exceptions, list) else []: + size = item.get('requested_allocation_bytes') if isinstance(item, dict) else None + if type(size) is int and size >= rows * SEQUENCE_ROW_BYTES: + return True + return False + + +def compute_recovery(engine, fixed_heads=False): """The next smaller compute partition for the bounded inference path, or None. Integers only shrink and flags only switch on, so recovery never widens a - partition that already failed. + partition that already failed. ``fixed_heads`` leaves the head group alone + and offers only the remaining partitions and flags. """ if engine.get('inference_kernels') is not True or type(engine.get('head_chunk')) is not int or engine['head_chunk'] <= 0: return None for rung in COMPUTE_RECOVERY: updates = {} for name, target in rung.items(): + if fixed_heads and name in HEAD_GROUP_KEYS: + continue current = engine.get(name, COMPUTE_DEFAULTS.get(name)) if target == 'half': # Once: a group of 2 or fewer heads goes on to the bounded rung. @@ -539,6 +562,7 @@ def change(section, name, new): options[name] = new host_pins = failure['kind'] == 'gpu_oom' and nonlocal_exhausted(failure) compute = None + heads_fixed = False if failure['kind'] == 'gpu_oom': if phase == 'sample' and engine.get('step_cache') == 'fbcache' and not host_pins: # Its two packed residuals are the newest GPU allocation in the @@ -602,7 +626,12 @@ def change(section, name, new): and engine.get('window_batch', 1) == 1): change('engine', 'residual_offload', True) if not changes and phase == 'sample': - compute = compute_recovery(engine) + # A whole-sequence buffer that already failed with a group of two or + # fewer heads does not shrink with the group: the retry would only + # repeat the completed steps and fail in the same place. + heads_fixed = (type(engine.get('head_chunk')) is int and engine['head_chunk'] <= 2 + and sequence_sized_failure(failure, canvas)) + compute = compute_recovery(engine, fixed_heads=heads_fixed) for name, new in (compute or {}).items(): change('engine', name, new) # Offloading more GPU weights must not turn the next attempt @@ -663,6 +692,9 @@ def change(section, name, new): changes={}, numerical_class='placement-only', measurement=failure, ram_recovery=ram_recovery) if not changes: + if heads_fixed: + return None, {'reason': 'The refused allocation is a whole-sequence buffer that a smaller head group ' + 'does not shrink, and no other placement or partition is left.', 'changes': {}} return None, {'reason': 'No further placement or compute-partition recovery is available.', 'changes': {}} if 'policy' in value: value['policy'].update(engine=dict(engine), decoder=dict(decoder)) From 23ac5be9ea3de1bb861d9d987d0d64d7cd8fdf23 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 01:36:12 +0000 Subject: [PATCH 25/43] Launcher report form names the real failure, not a ready download-tool check --- freevideo_engine/launcher_issue.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/launcher_issue.py b/freevideo_engine/launcher_issue.py index 222f7f79..1d5152cc 100644 --- a/freevideo_engine/launcher_issue.py +++ b/freevideo_engine/launcher_issue.py @@ -57,7 +57,10 @@ def gib(key): def issue_url(error, version=None, title=None, zh=False, environment=''): lines = _lines(error) - first = (lines[-1] if lines and lines[0].startswith('Traceback') else (lines[0] if lines else '')).strip() + # A successful download-tool check can open the text (its Sysnative "not found" is normal); the cause is not that line. + from .failure_details import _without_ready_curl_diagnostics + causes = _lines(_without_ready_curl_diagnostics(str(error))) or lines + first = (causes[-1] if causes and causes[0].startswith('Traceback') else (causes[0] if causes else '')).strip() place = '启动器' if zh else 'Launcher' problem = ('FreeVideo 启动器显示了一个错误。\n' if zh else 'The FreeVideo launcher showed an error.\n') + first[:300] report = '\n'.join(lines) From 2648b2e08a393a0e42684c1a626fd7f459e3c02d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 01:59:47 +0000 Subject: [PATCH 26/43] The GPU precheck before an int8 switch no longer requires model folders that the same setup... The GPU precheck before an int8 switch no longer requires model folders that the same setup downloads afterwards The precheck before switching an installation to int8 only checks that the GPU is ready; the model folders are checked after the install, including when the earlier GPU test results are reused. --- freevideo_engine/bootstrap.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 0ab48a33..fc5ca45f 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -2269,7 +2269,7 @@ def machine_configuration(self, python, encoder_python, comfy, prepared): def device_configuration(self): return {'gpu_uuid': self.plan['inventory']['selected_gpu']['uuid']} - def check_kernels(self, python, kernel_report): + def check_kernels(self, python, kernel_report, *, paths=True): # Kernel probes execute real CUDA work and take roughly a minute on a # consumer GPU. A completed receipt is reusable only when every # input that can change the result still matches: GPU identity, @@ -2281,11 +2281,18 @@ def check_kernels(self, python, kernel_report): receipt = (reusable_kernel_receipt(cached_kernel, hardware_data) if isinstance(hardware_data, dict) else None) if receipt is None: - self.command('kernels', [python, '-m', 'freevideo_engine', 'doctor', '--probe', '--require-paths', - *(['--without-video-models'] if self.plan.get('only') == 'image' else []), '--out', kernel_report]) + self.command('kernels', [python, '-m', 'freevideo_engine', 'doctor', '--probe', + *(['--require-paths'] if paths else []), + *(['--without-video-models'] if paths and self.plan.get('only') == 'image' else []), '--out', kernel_report]) else: save(kernel_report, receipt) self.ui.event('setup_cache', key='kernels', detail='Reused matching GPU kernel checks') + if paths: + # The receipt may come from the precheck, which does not look at the model folders. Check only the + # folders now (no probe, so the receipt stays as it is); a missing one fails this step as a fresh run would. + self.command('kernels', [python, '-m', 'freevideo_engine', 'doctor', '--require-paths', + *(['--without-video-models'] if self.plan.get('only') == 'image' else []), + '--out', Path(kernel_report).with_name('kernel-paths.json')]) def record_kernel_validation(self, results, kernel_report): sage_manifest = results['sage'] @@ -2307,7 +2314,7 @@ def execute(self): # the receipt is reused by the check after installation. self.ui.phase('Verify installation on your GPU', 0, 8) report = self.run_dir / 'kernel-capabilities.before.json' - self.check_kernels(self.pythons['engine'], report) + self.check_kernels(self.pythons['engine'], report, paths=False) # the model folders are installed after this check require_int8_kernels(self.plan, report) self.mark_unfinished() self.ui.phase('Prepare download tools', 0, 8) From 8bdfcbeacc50a22ec1330b064f464fc622b54442 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 02:12:27 +0000 Subject: [PATCH 27/43] Keep slow Python runtime downloads patient when a mirror refuses access, and add the public npm... Keep slow Python runtime downloads patient when a mirror refuses access, and add the public npm mirror as a source --- freevideo.ps1 | 18 +++++++++++++--- freevideo_engine/bootstrap.py | 15 ++++++++++--- freevideo_engine/network.py | 25 +++++++++++++++++---- freevideo_engine/windows_bootstrap.ps1 | 8 +++++++ scripts/bootstrap_linux.sh | 30 +++++++++++++++++++++----- 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/freevideo.ps1 b/freevideo.ps1 index 468d1149..94bf99bf 100644 --- a/freevideo.ps1 +++ b/freevideo.ps1 @@ -96,11 +96,18 @@ try { $env:UV_NO_CONFIG = '1' $installed = $false $pythonRoutes = @($routes | ForEach-Object { "$_/astral-sh/python-build-standalone/releases/download" }) + if (-not $officialOnly) { + # The GitHub mirror refuses the Python runtime files: the public npmmirror copy + # (same SHA-256 that uv pins) is tried before it. Keep in sync with network.py. + $pythonRoutes = @($pythonRoutes[0]) + @('https://registry.npmmirror.com/-/binary/python-build-standalone') + @($pythonRoutes | Select-Object -Skip 1) + } if (-not $officialOnly -and $freevideoSavedEnvironment['UV_PYTHON_INSTALL_MIRROR']) { $pythonRoutes = @($freevideoSavedEnvironment['UV_PYTHON_INSTALL_MIRROR']) + $pythonRoutes } $attempt = 0 - $allNetworkFailures = $true + $otherFailures = $false + $slowRoutes = @{} + $deniedRoutes = @{} # Keep these defaults and the notice in sync with network.py (CPU tests). $pythonRetryProfiles = @( @{Mode='fast'; UV_HTTP_TIMEOUT='20'; UV_HTTP_RETRIES='1'}, @@ -109,7 +116,9 @@ try { foreach ($profile in $pythonRetryProfiles) { if ($installed) { break } if ($profile.Mode -eq 'patient') { - if (-not $allNetworkFailures) { break } + if ($otherFailures) { break } + $retryRoutes = @($slowRoutes.Keys | Where-Object { -not $deniedRoutes.ContainsKey($_) }) + if ($retryRoutes.Count -eq 0) { break } Write-Host 'The network is slow. Downloading again; this may take a while.' $patientDownloadNotice = $true if ($env:FREEVIDEO_UI_EVENTS -eq '1') { @@ -122,6 +131,7 @@ try { [Environment]::SetEnvironmentVariable($name, $value, 'Process') } foreach ($route in $pythonRoutes) { + if ($profile.Mode -eq 'patient' -and $deniedRoutes.ContainsKey($route)) { continue } foreach ($proxyRoute in $proxyRoutes) { if ($installed) { break } $attempt++ @@ -135,7 +145,9 @@ try { -LogPath $bootstrapLog -Label $pythonLabel ` -Direct:($proxyRoute -eq 'direct') -OutputTail ([ref]$pythonOutput) if ($pythonExit -eq 0) { $installed = $true; break } - if (-not (Test-FreeVideoSlowDownloadFailure $pythonOutput)) { $allNetworkFailures = $false } + if (Test-FreeVideoAccessDenied $pythonOutput) { $deniedRoutes[$route] = $true } + elseif (Test-FreeVideoSlowDownloadFailure $pythonOutput) { $slowRoutes[$route] = $true } + else { $otherFailures = $true } Write-Host "Python install source $attempt returned exit code $pythonExit. Details retained in $bootstrapLog" $pythonReason = Get-FreeVideoFailureReason $pythonOutput if ($pythonReason) { Write-Host "uv reported: $pythonReason" } diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index fc5ca45f..0cb2f99f 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -1211,7 +1211,7 @@ def setup_task(label): """Describe the work independently of internal command IDs and log paths.""" routes = {'official': 'official source', 'nju': 'Nanjing University mirror', 'tuna': 'Tsinghua mirror', 'ghfast': 'GitHub mirror', - 'user-python': 'your Python mirror', 'user': 'your configured source'} + 'npmmirror': 'npm mirror', 'user-python': 'your Python mirror', 'user': 'your configured source'} # Python route retries append the route after the source. label = re.sub(r'-(?:direct|proxy)$', '', label) source = next((name for name in routes if label.endswith('-' + name)), None) @@ -1890,6 +1890,12 @@ def packages(self, label, args, family='pypi'): lambda env, source: self.command(label if source == 'official' else label + '-' + source, args, env=env), self.env, notice=notice) + def python_sources(self, names): + """The GitHub mirror refuses the Python runtime files, so it is tried after a mirror that serves them.""" + if self.network.get('mode') == 'official': + return names + return [*[n for n in names if n != 'ghfast'], *network.PYTHON_MIRRORS, *[n for n in names if n == 'ghfast']] + def python_install(self, uv): from .system import bootstrap_root prepared = bootstrap_root(self.root) / 'python' @@ -1920,13 +1926,16 @@ def python_install(self, uv): def attempt(source, route, retry_mode): env = network.download_environment( network.route_environment(self.network, 'github', source, self.env, route), retry_mode) - env['UV_PYTHON_INSTALL_MIRROR'] = custom if source == 'user-python' else network.source_url('github', source, self.env) + '/astral-sh/python-build-standalone/releases/download' + env['UV_PYTHON_INSTALL_MIRROR'] = (custom if source == 'user-python' else network.PYTHON_MIRRORS[source] + if source in network.PYTHON_MIRRORS else + network.source_url('github', source, self.env) + '/astral-sh/python-build-standalone/releases/download') return self.command('python-' + source + '-' + route, [uv, 'python', 'install', *isolation, self.versions['python']], env=env) try: with self.download_notice('download-patient-python') as notice: return network.download_command(self.network, 'github', attempt, self.env, category='python', - extra_sources=['user-python'] if custom else [], notice=notice) + extra_sources=['user-python'] if custom else [], notice=notice, + reorder=self.python_sources) except network.PackageSourcesError as error: failure = RuntimeError('Python download failed on every route; see retained logs' + network.sources_text(error.attempts)) diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 91cf414e..83b06457 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -44,6 +44,10 @@ 'cuda': {'official': 'https://developer.download.nvidia.com/compute/cuda/redist', 'nvidia-cn': 'https://developer.download.nvidia.cn/compute/cuda/redist'}, } +# Public mirror of the python-build-standalone release files, used only for the +# Python runtime download (uv's UV_PYTHON_INSTALL_MIRROR layout). Every file uv +# fetches from it is still checked against the SHA-256 uv pins for that release. +PYTHON_MIRRORS = {'npmmirror': 'https://registry.npmmirror.com/-/binary/python-build-standalone'} PROXY_KEYS = proxy.PROXY_KEYS SAMPLE_BYTES = 256 * 1024 SPEED_SAMPLE_BYTES = 32 * 2**20 @@ -673,7 +677,7 @@ def source_host(family, name, env=None): try: return _host(source_url(family, name, env)) except (KeyError, ValueError): - return '' + return _host(PYTHON_MIRRORS[name]) if family == 'github' and name in PYTHON_MIRRORS else '' def exit_code(error): @@ -784,6 +788,10 @@ def slow_download_failure(detail): 'permission denied', 'no space left on device', 'disk full'))) +def _access_denied(detail): + return bool(re.search(r'\b(?:401|403)\b', re.sub(r'https?://\S+', '', detail.lower()))) + + def package_environment(network, family, name, environ=None, *, route=None, retry_mode='fast'): env = route_environment(network, family, name, environ, route) env = download_environment(env, retry_mode) @@ -796,7 +804,7 @@ def package_environment(network, family, name, environ=None, *, route=None, retr return env -def download_command(network, family, run, env=None, *, category=None, extra_sources=(), notice=None): +def download_command(network, family, run, env=None, *, category=None, extra_sources=(), notice=None, reorder=None): """Run a source/route callback with the same bounded policy for uv and pip. Callbacks receive (source, route, retry_mode); each builds its child env from @@ -807,7 +815,14 @@ def download_command(network, family, run, env=None, *, category=None, extra_sou attempts = [] for retry_mode, timeout, retries in DOWNLOAD_RETRY_PROFILES: if retry_mode == 'patient': - if not attempts or not all(slow_download_failure(row['detail']) for row in attempts): + # A mirror that refuses access (HTTP 401/403) says nothing about a + # source that merely timed out: it is left out of the slow retry + # and must not cancel it. Any other non-network failure still ends + # the download here. + denied = {row['source'] for row in attempts if _access_denied(row['detail'])} + if (not attempts or all(row['source'] in denied for row in attempts) + or not all(slow_download_failure(row['detail']) or _access_denied(row['detail']) + for row in attempts)): break event(network, category=category, action='download-round-result', retry_mode='fast', result='failed', attempts=len(attempts)) @@ -823,7 +838,9 @@ def download_command(network, family, run, env=None, *, category=None, extra_sou event(network, category=category, action='download-round-start', retry_mode=retry_mode) # Re-read health and allowed routes after the fast pass. Equal failures # retain the existing probe order; no new connectivity probes are made. - for name in [*extra_sources, *ordered(network, family)]: + for name in [*extra_sources, *(reorder(ordered(network, family)) if reorder else ordered(network, family))]: + if retry_mode == 'patient' and name in denied: + continue for route in route_order(network, family, name, env): if operation == 'package' or retry_mode == 'patient': event(network, category=category, source=name, route=route, diff --git a/freevideo_engine/windows_bootstrap.ps1 b/freevideo_engine/windows_bootstrap.ps1 index b6f9acbb..10f96ff1 100644 --- a/freevideo_engine/windows_bootstrap.ps1 +++ b/freevideo_engine/windows_bootstrap.ps1 @@ -74,6 +74,14 @@ function Get-FreeVideoFailureReason { return $reason } +function Test-FreeVideoAccessDenied { + param([string]$Detail) + # Keep aligned with network._access_denied: a mirror that refuses access (HTTP 401/403) + # neither cancels the slow retry of a source that timed out nor is retried itself. + $Detail = $Detail -replace 'https?://\S+', '' + return ($Detail -match '\b(401|403)\b') +} + function Test-FreeVideoSlowDownloadFailure { param([string]$Detail) # Keep the patient-round gate aligned with network.slow_download_failure. diff --git a/scripts/bootstrap_linux.sh b/scripts/bootstrap_linux.sh index ca98981d..fda7cff9 100644 --- a/scripts/bootstrap_linux.sh +++ b/scripts/bootstrap_linux.sh @@ -218,6 +218,14 @@ fv_bootstrap_network() { return 1 } +# Match network._access_denied: a mirror that refuses access (HTTP 401/403) neither +# cancels the slow retry of a source that timed out nor is retried itself. +fv_access_denied() { + local detail="${1,,}" + while [[ "$detail" =~ https?://[^[:space:]]+ ]]; do detail=${detail//"${BASH_REMATCH[0]}"/}; done + [[ "$detail" =~ (^|[^[:alnum:]_])(401|403)($|[^[:alnum:]_]) ]] +} + fv_download_uv() { local url route attempt=0 archive="$FREEVIDEO_BOOTSTRAP_ROOT/uv-$FV_UV_VERSION.tar.gz" part local -a sources=("$FV_UV_URL") @@ -270,7 +278,8 @@ fv_install_python() ( fv_note "Files and log: $FREEVIDEO_BOOTSTRAP_ROOT" fv_download_uv || exit 1 local uv="$FREEVIDEO_BOOTSTRAP_ROOT/tools/uv-x86_64-unknown-linux-gnu/uv" source route attempt=0 - local retry_mode timeout retries attempt_log attempt_dir attempt_detail python_exit tee_exit all_network_failures=1 + local retry_sources retry_mode timeout retries attempt_log attempt_dir attempt_detail python_exit tee_exit other_failures=0 failed_source + local -A fv_denied=() fv_slow=() local -a pipe_status # The capture only classifies failures: without a writable place for it the # install still runs, it just never starts the slow-network round. @@ -282,6 +291,9 @@ fv_install_python() ( if [[ "$fv_network" != official ]]; then if [[ -n "${UV_PYTHON_INSTALL_MIRROR:-}" ]]; then sources=("$UV_PYTHON_INSTALL_MIRROR" "${sources[@]}"); fi if [[ -n "${FREEVIDEO_GITHUB_MIRROR:-}" ]]; then sources+=("${FREEVIDEO_GITHUB_MIRROR%/}/astral-sh/python-build-standalone/releases/download"); fi + # The public npmmirror copy of the release files (the same SHA-256 uv pins); + # the GitHub mirror below refuses these files, so it is tried last. + sources+=(https://registry.npmmirror.com/-/binary/python-build-standalone) sources+=(https://ghfast.top/https://github.com/astral-sh/python-build-standalone/releases/download) fi export UV_PYTHON_INSTALL_DIR="$FREEVIDEO_BOOTSTRAP_ROOT/python" UV_CACHE_DIR="$FREEVIDEO_BOOTSTRAP_ROOT/cache" @@ -289,11 +301,15 @@ fv_install_python() ( for retry_mode in fast patient; do timeout=20 retries=1 if [[ "$retry_mode" == patient ]]; then - (( all_network_failures )) || break + (( other_failures )) && break + retry_sources=0 + for failed_source in "${!fv_slow[@]}"; do [[ -n "${fv_denied[$failed_source]:-}" ]] || retry_sources=1; done + (( retry_sources )) || break timeout=120 retries=5 fv_note 'The network is slow. Downloading again; this may take a while.' fi for source in "${sources[@]}"; do + [[ "$retry_mode" != patient || -z "${fv_denied[$source]:-}" ]] || continue for route in "${fv_routes[@]}"; do ((attempt+=1)) fv_note "Installing private Python, attempt $attempt ($route connection)…" @@ -326,9 +342,13 @@ fv_install_python() ( fi if (( python_exit == 0 )); then fv_python_ok "$UV_PYTHON_INSTALL_DIR/cpython-$FV_PYTHON_VERSION-linux-x86_64-gnu/bin/python3.12" && exit 0 - all_network_failures=0 - elif ! fv_slow_download_failure "$attempt_detail"; then - all_network_failures=0 + other_failures=1 + elif fv_access_denied "$attempt_detail"; then + fv_denied[$source]=1 + elif fv_slow_download_failure "$attempt_detail"; then + fv_slow[$source]=1 + else + other_failures=1 fi fv_note 'Python preparation failed; retrying the next source. Existing files retained.' done From 1d14240a3bfe62791f4687fbb0a45af3bde2f06f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 02:13:35 +0000 Subject: [PATCH 28/43] Image generation: save results as high-quality JPEG and remove the page's temporary reference... Image generation: save results as high-quality JPEG and remove the page's temporary reference and mask uploads after each job --- freevideo_engine/image_gen/service.py | 42 +++++++++++++++++++++++++-- freevideo_engine/image_gen/worker.py | 18 ++++++++---- web/image_generation.js | 19 +++++------- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index bdff27f1..8a5cfa70 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -32,8 +32,9 @@ H3_MAX_SIDE = 8192 H3_GROUP = 'h3-image:' + image_pack.default_tier() REPORT_LIMIT = 16 -HISTORY_NAME = re.compile(r'image-\d{8}-\d{6}-\d{2}\.png') +HISTORY_NAME = re.compile(r'image-\d{8}-\d{6}-\d{2}\.(?:png|jpg)') LEGACY_NAME = re.compile(r'[0-9a-f]{32}\.png') +PAGE_UPLOAD = re.compile(r'freevideo-image-(?:reference|mask)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png') HISTORY_LIMIT = 500 # The 高清 size is offered from this much card memory; smaller cards keep one size. HIGH_SIZE_MIN_TOTAL = 6 * 1024 ** 3 @@ -137,6 +138,7 @@ def __init__(self, installation, input_directory, *, video_busy=None, idle_secon self.state = {} self.preview = None # {'serial', 'jpeg'}: the running job's newest step picture self._busy = False + self.uploads = OrderedDict() self.closed = False self.yielding = False self.reports = OrderedDict() @@ -312,7 +314,7 @@ def output_path(self, named=False): return str(output / (uuid.uuid4().hex + '.png')) stamp = time.strftime('image-%Y%m%d-%H%M%S') for number in range(1, 100): - path = output / ('%s-%02d.png' % (stamp, number)) + path = output / ('%s-%02d.jpg' % (stamp, number)) if not path.exists(): return str(path) raise ValueError('invalid_media') @@ -572,6 +574,16 @@ def request(self, value): return result def start(self, action, value): + try: + return self.admit(action, value) + except BaseException: + # A request turned away at the door leaves no job to clean up after it. + images = value.get('images') if isinstance(value, dict) else None + if isinstance(images, list): + self.remove_uploads([*images[:REPORT_LIMIT], value.get('mask')]) + raise + + def admit(self, action, value): if action not in ('generate', 'compose'): raise ValueError('invalid_request') context = Context(action, value) @@ -595,6 +607,9 @@ def start(self, action, value): # Only the picture page asks for this; the Agent's own choice leaves the page's menu alone. image_model.remember(machine['model_root'], raw.get('model')) key = secrets.token_urlsafe(24) + self.uploads[key] = [name for name in [*(raw.get('images') or []), raw.get('mask')] if isinstance(name, str)] + while len(self.uploads) > REPORT_LIMIT: + self.uploads.popitem(last=False) if waiting: self.queued = dict(key=key, action=action, value=raw, context=context, state=dict(phase='queued', queued_behind='video' if video else 'image'), @@ -953,14 +968,35 @@ def matches(candidate): row.update(forecast.view()) if row.get('phase') in ('progress', 'cancelling') and getattr(self.context, 'report_id', None): row['report_id'] = self.context.report_id + if row.get('phase') in ('complete', 'failed', 'cancelled'): + self.drop_uploads(self.key) return row if self.queued is not None and matches(self.queued['key']): return deepcopy(self.queued['state']) for candidate, state in self.finished.items(): if matches(candidate): + self.drop_uploads(candidate) return deepcopy(state) raise ValueError('unknown_job') + def drop_uploads(self, key): + """Remove the reference pictures and mask the page uploaded for a finished job; nothing else is touched.""" + self.remove_uploads(self.uploads.pop(key, [])) + + def remove_uploads(self, names): + try: + directory = Path(self.input_directory()).resolve() + except (OSError, ValueError): + return + for name in names: + if isinstance(name, str) and PAGE_UPLOAD.fullmatch(name): + path = directory / name + if path.is_file() and not path.is_symlink(): + try: + path.unlink() + except OSError: + pass + def cancel(self, key): with self.mutex: row = self.status(key) @@ -1324,7 +1360,7 @@ def infer(self, session, value): raise RuntimeError('worker_exit') from PIL import Image with Image.open(path) as image: - if image.format != 'PNG' or image.size != (value['width'], value['height']): + if image.format != ('JPEG' if path.suffix == '.jpg' else 'PNG') or image.size != (value['width'], value['height']): raise RuntimeError('worker_exit') image.verify() seconds = row.get('seconds') diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index 60d39455..b1b0afdf 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -205,13 +205,19 @@ def generate(seed): # The parent chooses a fresh path under ComfyUI input. # Exclusive creation never replaces an existing user file. plan = runner.stats().get('plan') - info = None - if isinstance(plan, dict) and type(plan.get('seed')) is int: - from PIL.PngImagePlugin import PngInfo - info = PngInfo() - info.add_text('seed', str(plan['seed'])) + seed = plan.get('seed') if isinstance(plan, dict) and type(plan.get('seed')) is int else None with Path(value['path']).open('xb') as target: - image.save(target, format='PNG', **(dict(pnginfo=info) if info else {})) + if Path(value['path']).suffix.lower() == '.jpg': + # Quality 95 without chroma subsampling is visually lossless at about a quarter of the PNG size. + image.convert('RGB').save(target, format='JPEG', quality=95, subsampling=0, + **(dict(comment=('seed=%d' % seed).encode()) if seed is not None else {})) + else: + info = None + if seed is not None: + from PIL.PngImagePlugin import PngInfo + info = PngInfo() + info.add_text('seed', str(seed)) + image.save(target, format='PNG', **(dict(pnginfo=info) if info else {})) if back_check is not None: plan = dict(plan or {}, back_check=back_check) result = dict(phase='result', path=value['path'], seconds=time.monotonic() - started, plan=plan) diff --git a/web/image_generation.js b/web/image_generation.js index 18224811..541a29a5 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -412,18 +412,14 @@ export function addReference(list, item, limit) { } export const removeReference = (list, file) => list.filter(r => r.file !== file); // Media lives in folders the image worker does not read, so each extra picture is copied beside the -// first reference once and its copy is used again for later requests. -export async function uploadExtraReferences(client, references, render, copies, alive = () => true) { +// first reference for every request; the service removes the copies when the job ends. +export async function uploadExtraReferences(client, references, render, alive = () => true) { const names = []; for (const item of references) { - if (!copies.has(item.file)) { - const blob = await render(item.file); - if (!alive()) throw failure('cancelled'); - const name = await client.upload(new File([blob], `freevideo-image-reference-${crypto.randomUUID()}.png`, {type: 'image/png'})); - if (!alive()) throw failure('cancelled'); - copies.set(item.file, name); - } - names.push(copies.get(item.file)); + const blob = await render(item.file); + if (!alive()) throw failure('cancelled'); + names.push(await client.upload(new File([blob], `freevideo-image-reference-${crypto.randomUUID()}.png`, {type: 'image/png'}))); + if (!alive()) throw failure('cancelled'); } return names; } @@ -627,7 +623,6 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection library = [], resolve = file => file, icon = () => '', onRatioChange, onPlace, productVersion, fetchApi, resumeKey = null}) { let mode = 'edit', prompt = '', mounted = null, references = [], loras = null, tier = 'standard', past = []; let promptRequired = false, attentionTimer = null; - const copies = new Map(); // A reference is sent no larger than the largest picture the service makes. async function referencePNG(file) { const image = await renderer.image(file); @@ -643,7 +638,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection prepare: async (request, alive) => { const context = generationContext(getDoc(), getSelection(), request.mode); const images = request.mode === 'edit' ? [await uploadGenerationReference(renderer, client, context, alive), - ...await uploadExtraReferences(client, request.references || [], referencePNG, copies, alive)] : []; + ...await uploadExtraReferences(client, request.references || [], referencePNG, alive)] : []; // Only the selected part of the edited picture (the first) is changed. const mask = images.length ? await uploadGenerationMask(renderer, client, context, alive) : null; return {context: mask ? {...context, region: true} : context, images, mask}; From 53bece9483e4e1485ff28115eb5a22416e513f24 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 02:45:57 +0000 Subject: [PATCH 29/43] Waits say what is happening: prompt rewriting, size range, starting a generation, opening from... Waits say what is happening: prompt rewriting, size range, starting a generation, opening from the launcher, subtitle unpacking and Autopilot show their stage and elapsed time; a refused part keeps its reason on the generate bar --- freevideo_engine/subtitle_pack.py | 3 +- web/autopilot_chat.js | 26 ++++++++++++-- web/capability_view.css | 1 + web/capability_view.js | 26 ++++++++++---- web/editor.js | 6 ++-- web/editor_genbar.js | 29 ++++++++++++---- web/editor_text.js | 5 +-- web/health.js | 35 ++++++++++++++++++- web/prompt_enhance.js | 56 ++++++++++++++++++++++--------- 9 files changed, 150 insertions(+), 37 deletions(-) diff --git a/freevideo_engine/subtitle_pack.py b/freevideo_engine/subtitle_pack.py index ff97d279..2f792e77 100644 --- a/freevideo_engine/subtitle_pack.py +++ b/freevideo_engine/subtitle_pack.py @@ -304,8 +304,9 @@ def check(): if stop.is_set(): raise InterruptedError('cancelled') + # The phase lets the page say it is unpacking once every byte is in, instead of standing at 100%. def progress(value): - self._set(done=value.get('done', 0), total=value.get('total', 0)) + self._set(done=value.get('done', 0), total=value.get('total', 0), phase=value.get('phase')) try: install(groups, self.root, progress, check) self._set(state='done', reason=None) diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 596b1d55..a3c739e9 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -44,6 +44,12 @@ export const STAGE_TEXT = Object.freeze({ checking: ['Checking the results', '正在检查结果'], assembling: ['Joining the shots', '正在拼接镜头'], }); const WORKING = ['running', 'queued', 'waiting', 'cancelling'], VIDEOS = ['generate', 'assemble']; +// Beside the dots while the agent names no stage: the sentence is still on its way, or the agent has it and the seconds count. +export function waitingText(s, t) { + const m = Math.floor(s / 60), r = s % 60; + return s < 60 ? t(`Waiting for a reply · ${s} s`, `正在等待回复 · ${s} 秒`) + : t(`Waiting for a reply · ${m} min${r ? ` ${r} s` : ''}`, `正在等待回复 · ${m} 分${r ? ` ${r} 秒` : ''}`); +} // What a job with no picture of its own is doing, in the reader's language. const QUIET = {vlm: ['Checking', '检查中'], enhance: ['Enhancing prompt', '增强提示词']}; // Other jobs without a card keep their line while they run, and a finished one still marks the tab. @@ -1319,13 +1325,24 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const item = el('li', 'fv-chat-thinking'), mark = el('span', 'fv-chat-dots'); mark.append(el('i'), el('i'), el('i')); const activity = el('span', 'fv-chat-activity'); item.setAttribute('role', 'status'); item.setAttribute('aria-label', t('Thinking', '正在思考')); item.append(mark, activity); - return {element: item, update(text) { activity.textContent = text || ''; activity.hidden = !text; }}; + let timer = null; + const show = text => { activity.textContent = text || ''; activity.hidden = !text; }; + // A waiting line counts on its own; it stops once the dots leave the page or say something else. + const count = since => { + show(waitingText(Math.max(0, Math.floor((Date.now() - since) / 1000)), t)); + timer = item.isConnected || timer === null ? setTimeout(() => count(since), 500) : null; + }; + return {element: item, update(text, {since = null} = {}) { + clearTimeout(timer); timer = null; + if (text == null && since != null) count(since); else show(text); + }}; } const MAKE = {hello, examples, dots, user: () => message('user'), agent: () => message('agent'), job: jobCard, confirm: confirmCard}; // A rewritten sentence takes the place of the one it rewrites. What was said, asked and left unfinished for the old // words folds away; finished videos and pictures stay where they were. The session itself keeps every entry. let rewriting = null; + let waitingFor = null; function rewritten(entries) { const list = [...entries]; if (rewriting && list.some(entry => entry.id === rewriting.id)) rewriting = null; @@ -1399,7 +1416,12 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const thinking = tail?.type !== 'job' && tail?.type !== 'confirm' && (localPending.some(entry => !entry.error) || !tail?.error && tail?.role === 'user') && state.agent.connected === true && (state.agent.listening !== false || tail.taken === true) && (localPending.length > 0 || !state.abandoned); stopMode = thinking && localPending.length === 0; - if (thinking) wanted.push(['dots', 'dots', Object.hasOwn(STAGE_TEXT, state.stage) ? t(...STAGE_TEXT[state.stage]) : null]); + // With no stage named: Sending… until the agent has taken the sentence, then the seconds since it took it. + const sending = localPending.some(entry => !entry.error) || tail?.role === 'user' && tail.taken === false; + const waitKey = thinking && !sending ? `${state.session}:${tail?.id ?? tail?.seq}` : null; + if (waitKey !== waitingFor?.key) waitingFor = waitKey ? {key: waitKey, since: Date.now()} : null; + if (thinking) wanted.push(['dots', 'dots', Object.hasOwn(STAGE_TEXT, state.stage) ? t(...STAGE_TEXT[state.stage]) + : sending ? t('Sending…', '正在发送…') : null, {since: waitingFor?.since ?? null}]); const stick = follow || pinned; const before = log.children.length, keep = new Set(wanted.map(([key]) => key)); diff --git a/web/capability_view.css b/web/capability_view.css index 06936859..33af82ec 100644 --- a/web/capability_view.css +++ b/web/capability_view.css @@ -4,6 +4,7 @@ .fv-range-longest{color:var(--fv-muted);white-space:nowrap;font-size:11px;font-variant-numeric:tabular-nums} .fv-range-arrow{width:6px;height:6px;flex:none;margin-right:3px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(45deg);transition:transform .2s} .fv-range-entry[aria-expanded=true] .fv-range-arrow{transform:rotate(225deg)} +.fv-range-entry:disabled .fv-range-arrow{visibility:hidden} .fv-range-chart{position:relative;display:grid;grid-template-columns:34px minmax(0,1fr);padding-top:8px;margin-bottom:14px;scroll-margin-bottom:100px;font-variant-numeric:tabular-nums;user-select:none} .fv-range-y{position:relative} .fv-range-y>span{position:absolute;right:8px;transform:translateY(-50%);color:var(--fv-muted);font-size:11px;white-space:nowrap;line-height:1} diff --git a/web/capability_view.js b/web/capability_view.js index 4e5dd3d1..b5f73204 100644 --- a/web/capability_view.js +++ b/web/capability_view.js @@ -200,6 +200,10 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate const fold = () => { chart.hidden = !opened; entry.setAttribute('aria-expanded', String(opened)); }; fold(); let disposed = false, query = '', grid = null, surface = null, hover = null, controller = null, retry = null, request = 0, drawn = '', pointer = null; + // While the engine is still working out the table the entry stays in place, greyed, saying so; + // after three minutes of that it is treated like no table at all. + let estimating = false, estimatingSince = 0; + const ESTIMATE_LIMIT_MS = 3 * 60 * 1000; const position = (item, x, y) => { item.style.transform = `translate(${x}px,${y}px)`; }; const hideHover = () => { hover = null; plot.classList.remove('is-hover'); highlight(); }; function show(point, x, y) { @@ -273,10 +277,15 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate } function applyGrid(animate = false) { if (grid?.version === 1 && grid.status === 'ready') onGrid?.(grid); - surface = capabilitySurface(grid, getState()); element.hidden = !surface; + surface = capabilitySurface(grid, getState()); element.hidden = !surface && !estimating; + entry.disabled = !surface; onEstimate?.(); - if (!surface) { hideHover(); return; } - current(); paint(animate); + if (!surface) { + hideHover(); + if (estimating) { chart.hidden = true; entry.setAttribute('aria-expanded', 'false'); longest.textContent = t('Estimating…', '正在估算…'); } + return; + } + fold(); current(); paint(animate); } async function fetchGrid() { if (!query || disposed) return; @@ -288,19 +297,22 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate const next = await reply.json(); if (disposed || signal.aborted || query !== key || request !== id) return; if (next.status === 'computing') { + if (!estimating) { estimating = true; estimatingSince = Date.now(); } + if (Date.now() - estimatingSince > ESTIMATE_LIMIT_MS) estimating = false; grid = null; applyGrid(); - retry = setTimeout(fetchGrid, clamp(Number(next.retry_after_seconds) || 1, .1, 30) * 1000); return; + if (estimating) retry = setTimeout(fetchGrid, clamp(Number(next.retry_after_seconds) || 1, .1, 30) * 1000); + return; } - grid = next; applyGrid(true); + estimating = false; grid = next; applyGrid(true); } catch { - if (!disposed && !signal.aborted && query === key && request === id) { grid = null; applyGrid(); } + if (!disposed && !signal.aborted && query === key && request === id) { estimating = false; grid = null; applyGrid(); } } } function update() { if (disposed) return; const next = capabilityContext(node, getState()) || ''; if (next !== query) { - query = next; controller?.abort(); clearTimeout(retry); grid = null; drawn = ''; applyGrid(); + query = next; controller?.abort(); clearTimeout(retry); grid = null; drawn = ''; estimating = false; applyGrid(); if (query) fetchGrid(); } else applyGrid(true); } diff --git a/web/editor.js b/web/editor.js index 84261b63..bd5b28ca 100644 --- a/web/editor.js +++ b/web/editor.js @@ -375,7 +375,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (PARTS_LIMIT_CODES.includes(code)) { const [en, zh] = (value.said || []).map(text => typeof text === 'string' && text ? text.slice(0, 200) : null); const text = en && zh ? t(en, zh) : partsLimitText(code, t, Number.isInteger(value.limit) ? value.limit : null); - out(text); return {ok: false, reason: code, say: text}; + // A refused Generate keeps its reason on the bar's own line until the choice or the parts change. + if (step === 'generate' && genbar && out === notify) genbar.say(text, false, null, true); else out(text); + return {ok: false, reason: code, say: text}; } if (['busy', 'locked', 'text', 'audio_full', 'overlap', 'range', 'no_audio'].includes(code)) { if (['locked', 'audio_full', 'overlap', 'range'].includes(code)) tracks?.flashBlocked?.(value.displayFrame); @@ -1472,7 +1474,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const step = value => { const install = value?.install; if (install?.state !== 'running') { resolve(install?.state === 'done' || install?.reason || false); return false; } - report(install.total > 0 ? install.done / install.total : 0); return true; + report(install.total > 0 ? install.done / install.total : 0, install.phase); return true; }; store.installEars().then(value => { if (step(value)) store.watchEarsPack(step, reject); }, reject); }), diff --git a/web/editor_genbar.js b/web/editor_genbar.js index adce3479..989a66cc 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -25,6 +25,12 @@ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) export const timeEstimate = (seconds, t) => seconds == null ? '' : seconds < 60 ? t(`about ${Math.max(1, Math.ceil(seconds))} s`, `约 ${Math.max(1, Math.ceil(seconds))} 秒`) : t(`about ${Math.ceil(seconds / 60)} minutes`, `约 ${Math.ceil(seconds / 60)} 分钟`); +// A start of a few seconds reads "Starting…"; a longer one counts in the same form as the other waiting lines. +export const startingText = (s, t) => { + const m = Math.floor(s / 60), r = s % 60; + return s < 3 ? t('Starting…', '正在开始…') : s < 60 ? t(`Starting · ${s} s`, `正在开始 · ${s} 秒`) + : t(`Starting · ${m} min${r ? ` ${r} s` : ''}`, `正在开始 · ${m} 分${r ? ` ${r} 秒` : ''}`); +}; // Provisional copy keys for Iris; decode/save sentences follow Studio's stage vocabulary. export const CLIP_FINALIZATION_COPY = { prepareDecode: ['Preparing video output', '正在准备输出视频'], @@ -182,7 +188,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { }; const references = actions.references ? createEditorReferences({t, source: actions.references.source, size: actions.references.size, seconds: actions.references.seconds, error: value => actions.error(value, null, sayAtRefs), notify: sayAtRefs}) : null; if (references) { description.before(references.element); host.append(refNotice); } - let status = null, serial = 0, disposed = false, pressing = new Set(), previewSelection = null, estimateCache = null, noteTimer = null, queuedAt = null; + let status = null, serial = 0, disposed = false, pressing = new Set(), previewSelection = null, estimateCache = null, noteTimer = null, queuedAt = null, heldAt = null, openedAt = null, openTimer = null; // Which parts are generating, or how the job ended: a "Queued" line goes once this moves on. const progressKey = () => ['done', 'failed', 'cancelled', 'interrupted'].includes(status?.state) ? status.state : (status?.blocks || []).filter(block => block.state === 'running').map(block => block.id).join(','); @@ -198,17 +204,22 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { } finally { downloading = false; report.disabled = false; } } report.onclick = () => downloadFailure(); - function say(text, queued = false, where = null) { + // A held line is a reason the user acts on (such as a part-length limit): it stays until another + // part is chosen or the timeline is edited, instead of fading after a few seconds. + const heldKey = () => JSON.stringify([state.selection ?? null, state.step ?? 0]); + function say(text, queued = false, where = null, held = false) { if (disposed) return; queuedAt = text && queued ? progressKey() : null; + heldAt = text && held ? heldKey() : null; clearTimeout(noteTimer); note.textContent = text || ''; note.hidden = !text; if (text && where) note.append(issueButton(t, () => ({where, message: text}))); - if (text) noteTimer = setTimeout(() => { note.hidden = true; showService(); }, where ? 20000 : 6000); - else showService(); + if (text && !held) noteTimer = setTimeout(() => { note.hidden = true; showService(); }, where ? 20000 : 6000); + else if (!text) showService(); } async function render() { if (disposed) return; if (queuedAt != null && progressKey() !== queuedAt) say(''); + if (heldAt != null && heldKey() !== heldAt) say(''); const current = ++serial, selection = previewSelection || state.selection; const selectedFailure = !previewSelection && actions.selectedFailure?.(); if (failureId !== (selectedFailure?.id || null)) { @@ -236,6 +247,10 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { const busy = sending || held || (running || starting) && !other; // The selected part is still being sent: it can only be stopped once it has started. const opening = (starting || !!actions.selectionSending?.()) && !held; + // Nothing has started yet, so there is nothing to cancel; a long start counts its seconds instead. + clearTimeout(openTimer); + if (!opening) openedAt = null; + else { openedAt ??= Date.now(); openTimer = setTimeout(render, 500); } const stopped = ['failed', 'cancelled', 'interrupted'].includes(status?.state); host.dataset.mode = busy ? 'running' : selected ? 'selected' : 'idle'; host.dataset.voice = voiced || installing ? 'true' : dub?.phase === 'error' ? 'note' : 'false'; voicePanel?.update(); syncInstall(); installPanel?.update(); @@ -262,7 +277,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { hint.textContent = finding ? t('Finding the lines', '正在识别台词') : dubbing.dubbingText(Math.max(1, dub.index), dub.count, dub.remaining, t); } else if (narrated && !busy && narration.picking) { hint.hidden = false; hint.textContent = t('Click a sound clip on the timeline', '在时间线上点一个声音片段'); } else if (narrated && !busy && narration.voices().finding) { hint.hidden = false; hint.textContent = t('Finding the lines', '正在识别台词'); } - else if (opening) hint.textContent = t('Starting…', '正在开始…'); + else if (opening) hint.textContent = startingText(Math.floor((Date.now() - openedAt) / 1000), t); else if (held || waiting) hint.textContent = waiting && !held ? waitingText(status.waiting, t) : t('Queued', '排队中'); else if (cancelling) hint.textContent = t('Stopping…', '正在停止…'); else if (running) { @@ -424,7 +439,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { } // A specific reason is said once; the planning limits are already shown above. catch (error) { const result = actions.error(error, 'generate'); reason = result?.say || ''; - if (result?.say && !['chain_too_long', 'chain_part_too_short', 'chain_short_parts', 'timed_text', 'middle_frames'].includes(result.reason)) say(result.say, false, ['Editor generation', '剪辑器生成']); + if (result?.say && !['chain_too_long', 'chain_part_too_short', 'chain_short_parts', 'chain_source_full', 'timed_text', 'middle_frames'].includes(result.reason)) say(result.say, false, ['Editor generation', '剪辑器生成']); else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '没能开始,请重试。'); say(reason, false, ['Editor generation', '剪辑器生成']); } } finally { pressing.delete(key); render(); } return reason; @@ -481,7 +496,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { if (!value && note.contains(retryService)) { retryService.remove(); note.hidden = true; } showService(); }, getText() { expand.commit(); return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); if (bound && input.value !== bound.text) { dirty = true; written = input.value; } render(); }, - dispose() { disposed = true; ++serial; clearTimeout(noteTimer); stopPass?.(); stopEffort?.(); effortControl?.dispose(); passControl?.dispose(); document.removeEventListener('pointerdown', closePass, true); clearTimeout(refNoticeTimer); duration.dispose(); unsubscribe(); resize?.disconnect(); expand.dispose(); references?.dispose(); voicePanel?.dispose(); installPanel?.dispose(); voiceRow?.dispose(); assets?.dispose(); Object.values(pickers).forEach(picker => picker.dispose()); }}; + dispose() { disposed = true; ++serial; clearTimeout(noteTimer); clearTimeout(openTimer); stopPass?.(); stopEffort?.(); effortControl?.dispose(); passControl?.dispose(); document.removeEventListener('pointerdown', closePass, true); clearTimeout(refNoticeTimer); duration.dispose(); unsubscribe(); resize?.disconnect(); expand.dispose(); references?.dispose(); voicePanel?.dispose(); installPanel?.dispose(); voiceRow?.dispose(); assets?.dispose(); Object.values(pickers).forEach(picker => picker.dispose()); }}; } // A missing optional module leaves the editor's existing progress in place. diff --git a/web/editor_text.js b/web/editor_text.js index e89a942f..ac2236dd 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -67,7 +67,8 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, // Subtitles from the speech on the timeline. One press starts, the next cancels; the lines replace the subtitle track in one step. // `need` is what has to be downloaded before the first use, in bytes; the same press downloads it and goes on. const speech={buttons:new Set(),available:false,need:0,busy:false,loading:false,shown:null,job:null,stop:null,run:0}; - function speechLabel(fraction){speech.shown=fraction;const p=Math.round(Math.min(1,Math.max(0,Number(fraction)||0))*100),size=Math.max(1,Math.round(speech.need/1e6)),text=fraction==null?(speech.need?t(`Auto subtitles · Download ${size} MB`,`自动字幕 · 下载 ${size} MB`):t('Auto subtitles','自动字幕')):speech.loading?t(`Cancel · Downloading ${p}%`,`取消 · 下载 ${p}%`):t(`Cancel · ${p}%`,`取消 · ${p}%`);for(const b of speech.buttons){b.textContent=text;b.hidden=!(speech.available||speech.need);}} + // Once every byte is in, the download is unpacked; the button says so and keeps the width it had, so it does not jump. + function speechLabel(fraction,phase=null){speech.shown=fraction;const unpacking=fraction!=null&&speech.loading&&phase==='unpack',p=Math.round(Math.min(1,Math.max(0,Number(fraction)||0))*100),size=Math.max(1,Math.round(speech.need/1e6)),text=fraction==null?(speech.need?t(`Auto subtitles · Download ${size} MB`,`自动字幕 · 下载 ${size} MB`):t('Auto subtitles','自动字幕')):unpacking?t('Cancel · Unpacking','取消 · 正在解压'):speech.loading?t(`Cancel · Downloading ${p}%`,`取消 · 下载 ${p}%`):t(`Cancel · ${p}%`,`取消 · ${p}%`);for(const b of speech.buttons){if(unpacking&&!b.style.minWidth&&b.offsetWidth)b.style.minWidth=`min(${b.offsetWidth}px, 100%)`;else if(!unpacking)b.style.minWidth='';b.textContent=text;b.hidden=!(speech.available||speech.need);}} function speechEnd(text,where=null){speech.stop?.();speech.stop=null;speech.job=null;speech.busy=false;speech.loading=false;speechLabel(null);if(text)actions.notify?.(text,where);} const speechFailed=()=>t('Could not create subtitles','没能生成字幕'),speechSilent=()=>t('No speech found','没有听到说话声'); const speechDisk=()=>t('The disk is full. Free some space, then try again.','磁盘空间不足,请清理出空间后重试。'); @@ -91,7 +92,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, try{ if(speech.need){ speech.loading=true;speechLabel(0); - const done=await actions.speech.download(fraction=>{if(run===speech.run)speechLabel(fraction);}); + const done=await actions.speech.download((fraction,phase)=>{if(run===speech.run)speechLabel(fraction,phase);}); if(run!==speech.run)return; // `done` is true, or the reason the download stopped. const ability=done===true?await actions.speech.ability():null; diff --git a/web/health.js b/web/health.js index ddc933ce..b9a2d9f9 100644 --- a/web/health.js +++ b/web/health.js @@ -479,6 +479,33 @@ function extensionProblem(problem) { } const distinct = problems => problems.filter((problem, index) => problems.findIndex(other => other.text === problem.text) === index); +// Opening from the launcher: once the page has taken 3 s, it says it is opening and counts the seconds, +// in the look of the workspace's own opening screen, until FreeVideo is in front or a problem is shown. +export const openingText = (s, words = t) => { + const m = Math.floor(s / 60), r = s % 60; + return s < 60 ? words(`Opening FreeVideo · ${s} s`, `正在打开 FreeVideo · ${s} 秒`) + : words(`Opening FreeVideo · ${m} min${r ? ` ${r} s` : ''}`, `正在打开 FreeVideo · ${m} 分${r ? ` ${r} 秒` : ''}`); +}; +function beginOpening() { + let cover = null, timer = null; + const tick = () => { + const s = Math.floor(performance.now() / 1000); + // The workspace's own opening screen already says so; never two at once. + const own = document.querySelector('dialog.fv-workspace-opening[open]'); + if (s >= 3 && !own && !cover) { + cover = document.createElement('div'); cover.className = 'fv-page-opening'; + cover.setAttribute('role', 'status'); cover.setAttribute('aria-live', 'polite'); + Object.assign(cover.style, {position: 'fixed', inset: '0', zIndex: '2147482000', display: 'grid', placeItems: 'center', + boxSizing: 'border-box', padding: '24px', background: '#10151e', color: '#e6edf6', font: '16px/1.6 system-ui,sans-serif'}); + document.body.append(cover); + } + if (cover) { cover.hidden = !!own; cover.textContent = openingText(s); } + timer = setTimeout(tick, 500); + }; + tick(); + return {finish() { clearTimeout(timer); cover?.remove(); cover = null; }}; +} +let opening = null; let card = null, lastProblems = []; function details(problems) { const cache = problems.flatMap(p => p.cache || []); @@ -544,6 +571,7 @@ function addQuoteFaces() { } function show(problems) { + opening?.finish(); opening = null; lastProblems = problems; addQuoteFaces(); card?.remove(); @@ -659,6 +687,7 @@ document.addEventListener('visibilitychange', async () => { async function check() { [extensionUI, errorUI] = await Promise.all([import('./browser_extension.js').catch(() => null), import('./error_panel.js').catch(() => null)]); + if (launchVisit && !opening) opening = beginOpening(); send('loading', []); const app = await waitFor(comfy, 60000); if (!app) { @@ -671,6 +700,8 @@ async function check() { // the workflow is open and in front. await waitFor(() => window.FreeVideoUI?.ready, 10000); const launched = launchVisit ? await waitFor(() => reports.find(r => r.type === 'launch'), 30000) : null; + // FreeVideo is in front: the page itself now says what it is doing. + if (window.FreeVideoUI?.ready) { opening?.finish(); opening = null; } // Opening the workspace waits for an animation frame. await sleep(1500); const problems = []; @@ -682,6 +713,7 @@ async function check() { // extensions: it starts them one after another, and a slow one ahead of // FreeVideo's delays it. Give it the rest of the minute before naming it. if (!window.FreeVideoUI?.ready && !problems.length) await waitFor(() => window.FreeVideoUI?.ready, 50000); + opening?.finish(); opening = null; const notStarted = {text: t('FreeVideo’s scripts loaded, but ComfyUI did not start them.', 'FreeVideo 的脚本已下载,但 ComfyUI 没有启动它们。'), advice: 'reload'}; if (!window.FreeVideoUI?.ready && !problems.length) { problems.push(notStarted); @@ -741,5 +773,6 @@ async function check() { if (!window.__freevideoHealthStarted) { window.__freevideoHealthStarted = true; install(); - check().catch(error => consoleError.call(console, '[FreeVideo] Page check failed:', error)); + // Whatever happens in the check, the opening cover never outlives it. + check().catch(error => consoleError.call(console, '[FreeVideo] Page check failed:', error)).finally(() => { opening?.finish(); opening = null; }); } diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index db3c5366..d5970331 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -285,10 +285,31 @@ export function createPromptEnhancer({node, input, editor, api, context, setText catch (error) { if (running === request) { cancelling = false; render(); } throw error; } } cancel.onclick = () => cancelJob().catch(error => { show(errorText(error), 'error'); }); - async function poll(action, body) { + // The rewrite's waiting line: shown as soon as it is pressed, its seconds counted here every second, + // not only when a status reply comes back. `pause` hands the line to the download or memory wait. + function waitClock() { + let since = performance.now(), phase = 'preparing', timer = null; + const paint = () => { + if (!phase || disposed || cancelling) return; + const [en, zh] = phase === 'rewriting' ? ['Enhancing the prompt', '正在改写提示词'] : ['Preparing enhancement', '正在准备提示词增强']; + const s = Math.floor((performance.now() - since) / 1000), m = Math.floor(s / 60), r = s % 60; + show(s < 1 ? t(en + '…', zh + '…') : s < 60 ? t(`${en} · ${s} s`, `${zh} · ${s} 秒`) + : t(`${en} · ${m} min${r ? ` ${r} s` : ''}`, `${zh} · ${m} 分${r ? ` ${r} 秒` : ''}`), 'progress'); + }; + const clock = { + set(next) { const changed = next !== phase; phase = next; if (changed) paint(); }, + pause() { phase = ''; }, + restart() { since = performance.now(); phase = 'preparing'; paint(); }, + stop() { phase = ''; clearTimeout(timer); timer = null; }, + }; + // Twice a second so the count never skips a number; stops itself once the page is gone. + const tick = () => { paint(); timer = disposed ? null : setTimeout(tick, 500); }; + tick(); + return clock; + } + async function poll(action, body, clock = null) { if (disposed || cancelling) throw new Error('cancelled'); const started = await post(action, body); job = started.job; - const since = performance.now(), elapsed = () => Math.floor((performance.now() - since) / 1000); let failures = 0; if (disposed || cancelling) await post('cancel', {job}); try { @@ -321,18 +342,16 @@ export function createPromptEnhancer({node, input, editor, api, context, setText if (row.phase === 'cancelled') throw new Error(action === 'install' ? 'download_failed' : 'cancelled'); if (disposed) throw new Error('cancelled'); if (row.phase === 'download') { + clock?.pause(); showDownload(row.done || 0, row.total || 0); } else if (row.phase === 'waiting_gpu') { + clock?.pause(); // The rewrite starts by itself once enough graphics memory is free. const short = Math.max(0.1, (row.gpu_short_bytes || 0) / 2 ** 30).toFixed(1), names = holderNames(row.gpu_holders); show(names ? t(`Waiting for ${short}\u00a0GiB more graphics memory to rewrite. In use: ${names}.`, `显存不足,再空出 ${short}\u00a0GiB 后自动改写。正在占用:${names}。`) : t(`Waiting for ${short}\u00a0GiB more graphics memory to rewrite.`, `显存不足,再空出 ${short}\u00a0GiB 后自动改写。`), 'progress'); } else if (action !== 'install') { - // The ellipsis says "still going" until the seconds start counting. - const [en, zh] = row.phase === 'rewriting' ? ['Enhancing the prompt', '正在改写提示词'] : ['Preparing enhancement', '正在准备提示词增强']; - const s = elapsed(), m = Math.floor(s / 60), r = s % 60; - show(s < 1 ? t(en + '…', zh + '…') : s < 60 ? t(`${en} · ${s} s`, `${zh} · ${s} 秒`) - : t(`${en} · ${m} min${r ? ` ${r} s` : ''}`, `${zh} · ${m} 分${r ? ` ${r} 秒` : ''}`), 'progress'); + clock?.set(row.phase === 'rewriting' ? 'rewriting' : 'preparing'); } await new Promise(resolve => setTimeout(resolve, 400)); } @@ -404,14 +423,21 @@ export function createPromptEnhancer({node, input, editor, api, context, setText // The server counts what it receives, which includes the character descriptions the mentions are written out as. const text = prepare(original), characters = [...text].length; if (characters > PROMPT_LIMIT) throw Object.assign(new Error('invalid_prompt'), {characters, expanded: text !== original}); - const info = await readModel(); - if (disposed || cancelling) throw new Error('cancelled'); - if (!await installModel(info)) { - versions.value.enabled = false; - versions.select('original'); apply(); - throw new Error('cancelled'); - } - const result = await poll('rewrite', {text, ...scene}); + const clock = waitClock(); + let result; + try { + const info = await readModel(); + if (disposed || cancelling) throw new Error('cancelled'); + // The download offer and its progress own the line; the seconds start again after it. + if (!info.ready) { clock.pause(); show(''); } + if (!await installModel(info)) { + versions.value.enabled = false; + versions.select('original'); apply(); + throw new Error('cancelled'); + } + if (!info.ready) clock.restart(); + result = await poll('rewrite', {text, ...scene}, clock); + } finally { clock.stop(); } if (disposed || cancelling) throw new Error('cancelled'); const restored = restore(result.text); if (edits !== versions.value.edits || !versions.accept(key, context(), display(restored), restored)) throw new Error('stale'); From 9bd2d00d94adebf42ff9678c1555a04cabe381ea Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 03:26:13 +0000 Subject: [PATCH 30/43] A damaged reserve setting is kept aside, a refused save is explained under Save, the... A damaged reserve setting is kept aside, a refused save is explained under Save, the command-line upload shows the check stage and reference preparation counts files A damaged reserved-memory setting file is renamed and kept, and the settings panel and generation continue with automatic optimization. A failed save and a value under the lower limit are explained directly under the Save button. The command-line upload reports when the file is being checked. Preparing reference media reports which file is in progress. --- freevideo_engine/agent/cli.py | 3 +- freevideo_engine/agent/client.py | 18 ++++++--- freevideo_engine/comfy_bridge.py | 3 +- freevideo_engine/comfy_setup.py | 6 ++- freevideo_engine/media_encoding.py | 14 +++++++ freevideo_engine/progress_timeline.py | 1 + freevideo_engine/resource_settings.py | 55 ++++++++++++++++++++++++--- web/generation_progress.js | 2 +- web/setup.js | 35 ++++++++++++++--- 9 files changed, 117 insertions(+), 20 deletions(-) diff --git a/freevideo_engine/agent/cli.py b/freevideo_engine/agent/cli.py index 473135f3..3d6d1912 100644 --- a/freevideo_engine/agent/cli.py +++ b/freevideo_engine/agent/cli.py @@ -318,7 +318,8 @@ def source(value): path = path.replace('{id}', args.id) result = client.get(path, **value) if method == 'GET' else client.post(path, value) elif args.command == 'upload': - result = client.upload(args.path) + result = client.upload(args.path, stage=lambda name: print( + 'Uploading…' if name == 'sending' else 'Checking the file…', file=sys.stderr, flush=True)) elif args.command == 'imports': result = (client.import_media(args.path, **({'ref': args.ref} if hasattr(args, 'ref') else {})) if args.operation == 'add' else client.get('/imports')) diff --git a/freevideo_engine/agent/client.py b/freevideo_engine/agent/client.py index 7bdfc67a..9c580300 100644 --- a/freevideo_engine/agent/client.py +++ b/freevideo_engine/agent/client.py @@ -279,9 +279,11 @@ def asset(self, identity, target, *, thumbnail=False): path = '/freevideo/agent/v1/library/%s/%s' % (identity, 'thumbnail' if thumbnail else 'file') return self.download({'url': path}, target) - def upload(self, path): - """Read on the client computer and upload content; never send a local path.""" - return self._upload(path, '/media') + def upload(self, path, *, stage=None): + """Read on the client computer and upload content; never send a local path. + ``stage('sending')`` and ``stage('checking')`` say when the content goes out + and when the server is decoding it, which can take long for a large video.""" + return self._upload(path, '/media', stage=stage) def import_media(self, path, *, ref=None): """Import a client file into the editor. Poll /imports until its state is ready.""" @@ -290,9 +292,10 @@ def import_media(self, path, *, ref=None): schema.validate(schema.obj({'ref': schema.REF}), dict(ref=ref)) return self._upload(path, '/imports', ref=ref) - def _upload(self, path, endpoint, *, ref=None): + def _upload(self, path, endpoint, *, ref=None, stage=None): from .schema import MAX_UPLOAD_BYTES, MAX_JSON_UPLOAD_BYTES source = local_file(path) + stage = stage or (lambda name: None) try: size = source.stat().st_size if size > MAX_UPLOAD_BYTES: @@ -300,7 +303,10 @@ def _upload(self, path, endpoint, *, ref=None): dict(next='Reduce the file to at most %d bytes.' % MAX_UPLOAD_BYTES, details=[dict(limit_bytes=MAX_UPLOAD_BYTES)])) if size * 4 // 3 + 4096 <= MAX_JSON_UPLOAD_BYTES: if endpoint == '/media': - return self.upload_bytes(source.name, source.read_bytes()) + data = source.read_bytes() + # A small body goes out at once; the wait is the server's check. + stage('checking') + return self.upload_bytes(source.name, data) return self.post(endpoint, dict(name=source.name, data_base64=base64.b64encode(source.read_bytes()).decode(), **({'ref': ref} if ref is not None else {})), timeout=600) import secrets @@ -315,10 +321,12 @@ def _upload(self, path, endpoint, *, ref=None): if ref is not None else '') + '\r\n--%s--\r\n' % boundary).encode('ascii') with source.open('rb') as stream: def chunks(): + stage('sending') yield prefix while data := stream.read(1024 * 1024): yield data yield ending + stage('checking') return self.request('POST', '/freevideo/agent/v1' + endpoint, encoded=chunks(), content_type='multipart/form-data; boundary=' + boundary, content_length=len(prefix) + size + len(ending), timeout=600) diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index 7638797e..3c41ec76 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -1134,7 +1134,8 @@ def progress_message(event): if (group not in WORK_LABELS or type(done) is not int or type(total) is not int or not 0 <= done <= total or total < 1 or unit not in WORK_UNITS): return None - return dict(label=WORK_LABELS[group], timing_phase=group, phase=group, stage=stage, + label = 'Preparing reference media' if stage == 'media_prepare' else WORK_LABELS[group] + return dict(label=label, timing_phase=group, phase=group, stage=stage, done=done, total=total, unit=unit, elapsed_seconds=event.get('elapsed_seconds')) if name == 'media_encode_phase': return {'label': str(event.get('phase', 'Encoding input media')), 'timing_phase': 'encoding'} diff --git a/freevideo_engine/comfy_setup.py b/freevideo_engine/comfy_setup.py index 505b6212..a1045692 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -183,7 +183,7 @@ def discovery(self, download_root=None): libraries=discover_libraries(self.folder_paths), extra_libraries=extra_model_directories(), token=self.token, environment='Managed FreeVideo environment; ComfyUI packages are kept', restart_required=False, downloads=self.downloads(download_root or root), - resources=self.resource_settings(), + resources=self.resource_settings(), resources_damaged=self.resources_damaged(), sampling_cache_bytes=total_bytes()) @@ -217,6 +217,10 @@ def resource_settings(self, value=None): raise ValueError('Specify automatic (null) or ' + label + ' in GiB') return write(root, value[field], backend=backend) + def resources_damaged(self): + from .resource_settings import damaged + return damaged(installation_root(self.source)) + def preview_style(self, value=None): from .preview_style import read, write root = installation_root(self.source) diff --git a/freevideo_engine/media_encoding.py b/freevideo_engine/media_encoding.py index 55c566bc..5c50f8cb 100644 --- a/freevideo_engine/media_encoding.py +++ b/freevideo_engine/media_encoding.py @@ -167,6 +167,14 @@ def prepare(media, canvas, directory): keyframe_rows = ([('first', media['first'])] if media.get('first') else []) + [ ('mid:%d' % row['frame'], row) for row in media.get('middle', [])] + ( [('last', media['last'])] if media.get('last') else []) + # Decoding and normalizing a long reference video takes a while before any + # encoding starts; say which file is being prepared. + from .work_progress import WorkProgress + files = len(keyframe_rows) + len(media.get('references', [])) + work = WorkProgress('encoding', 'media_prepare', files, 'files', interval=0) if files else None + prepared = 0 + if work: + work.update(0) for anchor, info in keyframe_rows: c_hash = info.get('sha256') if not c_hash and info.get('path'): @@ -181,6 +189,8 @@ def prepare(media, canvas, directory): result.append({'kind': 'image', 'anchor': anchor, 'pixels': str(target), 'frames': 1, 'content_hash': c_hash, 'source_path': info['path'], 'width': canvas['width'], 'height': canvas['height'], 'crop_mode': 'fit'}) + prepared += 1 + work.update(prepared) for index, row in enumerate(media.get('references', [])): kind, path = row['kind'], row['path'] if kind == 'video' and row.get('voice_only'): @@ -194,6 +204,8 @@ def prepare(media, canvas, directory): result.append(dict(kind='audio', use='original', original_audio=info, audio=model_audio(info, canvas, target))) ref_items.append({'type': 'audio'}) + prepared += 1 + work.update(prepared) continue c_hash = row.get('sha256') if not c_hash and path: @@ -281,6 +293,8 @@ def prepare(media, canvas, directory): seconds=round(length, 2) if length and length > used else None) print(json.dumps(dict(event='reference_trimmed', **ref['trimmed'])), flush=True) result.append(ref) + prepared += 1 + work.update(prepared) (directory / 'normalization.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') rows, seconds = reference_load(result) if rows: diff --git a/freevideo_engine/progress_timeline.py b/freevideo_engine/progress_timeline.py index a5dc2b9f..3c3b694c 100644 --- a/freevideo_engine/progress_timeline.py +++ b/freevideo_engine/progress_timeline.py @@ -93,6 +93,7 @@ 'latent_validation', 'layers', 'media_encode_audio', + 'media_prepare', 'media_vae', 'offload_release', 'other', diff --git a/freevideo_engine/resource_settings.py b/freevideo_engine/resource_settings.py index 4fc0e7c3..5464570a 100644 --- a/freevideo_engine/resource_settings.py +++ b/freevideo_engine/resource_settings.py @@ -1,6 +1,8 @@ """Persistent ComfyUI resource preferences, read once for each new request.""" import json import math +import os +import time from pathlib import Path from .monitoring import save @@ -25,17 +27,58 @@ def validate(value, *, backend='cuda'): def read(root, *, backend='cuda'): field, _, _ = setting(backend) - path = Path(root) / 'resource-settings.json' + path = Path(root) / NAME if not path.exists(): return {field: None} - value = json.loads(path.read_text(encoding='utf-8')) - if not isinstance(value, dict): - raise ValueError('Invalid resource settings') - return {field: validate(value.get(field), backend=backend)} + try: + value = json.loads(path.read_text(encoding='utf-8')) + if not isinstance(value, dict): + raise ValueError('Invalid resource settings') + return {field: validate(value.get(field), backend=backend)} + except FileNotFoundError: + return {field: None} + except ValueError: + # A damaged file must not stop the settings panel or every generation: + # it is renamed and kept, and the automatic policy takes over. + keep(path) + return {field: None} + + +NAME = 'resource-settings.json' +DAMAGED = 'resource-settings.damaged-' + + +def keep(path): + stamp, count = DAMAGED + time.strftime('%Y%m%d-%H%M%S'), 1 + target = path.with_name(stamp + '.json') + while target.exists(): # A second damaged file within the same second keeps the first copy. + count += 1 + target = path.with_name('%s-%d.json' % (stamp, count)) + try: + os.replace(path, target) + except FileNotFoundError: + pass # Another reader already set it aside. + except OSError: + pass # Left in place; reading still falls back to automatic. + + +def damaged(root): + """The newest damaged copy's name while no settings were saved after it.""" + root = Path(root) + if (root / NAME).exists(): + return None + try: + names = sorted(item.name for item in root.glob(DAMAGED + '*.json')) + except OSError: + return None + return names[-1] if names else None def write(root, value, *, backend='cuda'): field, _, _ = setting(backend) result = {field: validate(value, backend=backend)} - save(Path(root) / 'resource-settings.json', result) + try: + save(Path(root) / NAME, result) + except OSError as error: + raise ValueError('Could not save resource settings: %s' % error) from error return result diff --git a/web/generation_progress.js b/web/generation_progress.js index bda7fdae..47a33f67 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -150,7 +150,7 @@ const DOWNLOADS = { }; // Stages whose engine counts are real completed work inside one long step. const COUNTED_STAGES = new Set(['video', 'media', 'weight_pin', 'weight_read', - 'encoder_device_load', 'encoder_compute', 'encoder_compute_reference', 'media_encode_audio']); + 'encoder_device_load', 'encoder_compute', 'encoder_compute_reference', 'media_encode_audio', 'media_prepare']); const COUNT_UNITS = {frames: ['frames', '帧'], layers: ['layers', '层'], files: ['files', '个文件'], chunks: ['parts', '段']}; const size = (bytes, unit) => (bytes / (unit === 'GiB' ? 2**30 : 2**20)).toFixed(1); // Asked for two passes with a first pass other than 8 steps: the engine ran one pass. diff --git a/web/setup.js b/web/setup.js index 2162640d..183ee7f6 100644 --- a/web/setup.js +++ b/web/setup.js @@ -34,6 +34,8 @@ const isBusy = value => BUSY_LOCK.test(value) || value.startsWith(BUSY_TEXT.en) const repairStep = text => text.includes('In the FreeVideo launcher, open Settings and click Repair.') ? t('In the FreeVideo launcher, open Settings and click Repair.', '请在 FreeVideo 启动器的设置里点“修复”。') : t('Click Detect & review, then Install / repair.', '请先点“检测并预览”,再点“安装/修复”。'); +// The reserve's lower limit, said the same way whether the panel or the service stopped the value. +const atLeast = least => t(`Enter at least ${least} GiB, or choose Automatic optimization.`, `请输入不小于 ${least} GiB 的数值,或勾选“自动优化”。`); const WAIT_REFUSAL = /^Wait for (the installation check|the installation|a placement test|a setup task) to finish; its progress is in this panel\.$/; const SETUP_ERRORS = [ // Another task holds the installation lock; any "Lock file open:" lines name the holders. @@ -56,6 +58,11 @@ const SETUP_ERRORS = [ }], [/^Inspect this installation again before installing$/, () => t('Click Detect & review again, then Install / repair.', '请重新点“检测并预览”,再点“安装/修复”。')], [/^Review the plan and accept its model\/toolkit licenses first$/, () => t('Tick the agreement above to enable Install / repair.', '勾选上方的同意选项后,可点击“安装/修复”。')], + // Saving the reserve: the panel's own limits normally stop these first. + [/^(Reserved VRAM|Reserved unified RAM) must be automatic or a finite number of at least ([\d.]+) GiB$/, + text => atLeast(text.match(/at least ([\d.]+) GiB$/)[1])], + [/^Could not save resource settings: /, + () => t('The setting could not be saved. Check that the installation folder can be written to.', '设置未能保存,请检查安装目录是否可以写入。')], [/^FreeVideo setup is missing\./, text => t('This folder has no finished FreeVideo installation. ', '该文件夹中没有已安装的 FreeVideo。') + repairStep(text)], [/^FreeVideo setup is incomplete in /, text => t('The installation in this folder is not finished. ', '该文件夹中的安装尚未完成。') + repairStep(text) + t(' Existing files are reused.', '已有文件会继续使用。')], @@ -168,11 +175,19 @@ export async function openSetup(options) { const reserveUnit = node('span'); reserveUnit.className = 'fv-unit'; reserveUnit.append(reserve, node('span', 'GiB')); reserveLabel.append(reserveUnit); const saveReserve = node('button', t('Save', '保存')), reserveStatus = node('span'); reserveStatus.className = 'fv-muted fv-saved'; const reserveHelp = note(t('Extra VRAM to keep free. Applies from the next generation.', '额外保留的空闲显存,下次生成起生效。')); - resources.append(autoLabel, reserveLabel, reserveHelp, saveReserve); + // A damaged saved file was set aside by the engine; this stays until a new value is saved. + const reserveDamaged = note(t('The saved setting was damaged, so Automatic optimization is in use. The original file was renamed and kept.', + '保存的设置已损坏,现按“自动优化”运行,原文件已改名保留。')); + reserveDamaged.hidden = true; + // A failed save is explained right under the Save button, the full width of the row. + const reserveFailure = node('p'); reserveFailure.className = 'fv-failure'; reserveFailure.hidden = true; + reserveFailure.setAttribute('aria-live', 'polite'); + resources.append(reserveDamaged, autoLabel, reserveLabel, reserveHelp, saveReserve, reserveFailure); reserveSummary.append(reserveStatus); advanced.append(resources); let savedTimer; - const clearReserveStatus = () => { clearTimeout(savedTimer); reserveStatus.textContent = ''; reserveStatus.className = 'fv-muted fv-saved'; }; + const clearReserveStatus = () => { clearTimeout(savedTimer); reserveStatus.textContent = ''; reserveStatus.className = 'fv-muted fv-saved'; + reserveFailure.replaceChildren(); reserveFailure.hidden = true; }; reserveStatus.setAttribute('aria-live', 'polite'); reserve.oninput = clearReserveStatus; reserveAuto.onchange = () => { reserve.disabled = reserveAuto.checked; clearReserveStatus(); }; @@ -381,8 +396,16 @@ export async function openSetup(options) { saveReserve.onclick = async () => { clearReserveStatus(); if (!reserveAuto.checked && !reserve.value) { - reserveStatus.className = 'fv-failure'; - reserveStatus.textContent = t('Enter a value.', '请输入数值。'); + reserveFailure.hidden = false; + reserveFailure.textContent = t('Enter a value.', '请输入数值。'); + reserveFailure.scrollIntoView({block: 'nearest'}); + reserve.focus(); + return; + } + if (!reserveAuto.checked && reserve.validity.rangeUnderflow) { + reserveFailure.hidden = false; + reserveFailure.textContent = atLeast(reserve.min); + reserveFailure.scrollIntoView({block: 'nearest'}); reserve.focus(); return; } @@ -390,10 +413,11 @@ export async function openSetup(options) { saveReserve.disabled = true; try { showResources(await request('resources', {[reserveField]: reserveAuto.checked ? null : Number(reserve.value)})); + reserveDamaged.hidden = true; reserveStatus.textContent = t('Saved · applies to the next request', '已保存 · 下次生成生效'); // The state sits at the row's end and fades after two seconds. savedTimer = setTimeout(() => { reserveStatus.classList.add('fv-fade'); savedTimer = setTimeout(clearReserveStatus, 400); }, 2000); - } catch (error) { reserveStatus.className = 'fv-failure'; fault(reserveStatus, error.message); } + } catch (error) { reserveFailure.hidden = false; fault(reserveFailure, error.message); reserveFailure.scrollIntoView({block: 'nearest'}); } finally { saveReserve.disabled = false; } }; function showDownloads(value) { @@ -591,6 +615,7 @@ export async function openSetup(options) { extra.value = (info.discovery.extra_libraries || []).join("\n"); showDownloads(info.discovery.downloads); showResources(info.discovery.resources); + if (info.discovery.resources_damaged) { reserveDamaged.hidden = false; resources.open = true; } samplingText.textContent = t('Prepare all quality levels', '提前下载全部质量档位') + (info.discovery.sampling_cache_bytes ? ' · ' + gib(info.discovery.sampling_cache_bytes) : ''); // New installations prepare every quality level; a ready one adds them only when chosen. From b7e4be36ac64cd91518f7a705fbd450a16e5cd4f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 03:51:20 +0000 Subject: [PATCH 31/43] A finished film also opens as an editing project, one part per shot The film video is unchanged; each part is noted with its shot's action, ready for finishing the sound. --- freevideo_engine/agent/guide.py | 3 + freevideo_engine/agent/productions.py | 88 +++++++++++++++++++ .../agent/skill/freevideo/references/film.txt | 1 + 3 files changed, 92 insertions(+) diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 5694be09..5d9942a6 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -197,6 +197,9 @@ 'job (30 s or more, or beyond the device limit, continues and joins automatically); to polish an idea first, run enhance, then generate with "prompt": {"from_job": ""} rather than retyping the text. ' 'A film is cut into shots: see the rule "Cut by default" before making one long continued video.', 'Use drama plan → start → get → sheet → choose/reshoot → finish; inspect todo after every change.', + 'Each finished output also gives project_id: the film opened as an editing project, one part per shot with its action as the note. ' + 'Finish the sound or change the cut on that project (projects get, then finish_sound); the MP4 in library_id stays as it was. ' + 'project_id is null where editing is not available.', 'A shot whose prompt has numbered cuts and several takes: do not stop at the first take unless every cut of it is good ' '(scores 7 or more of 10); wait for the other takes, read segments, and choose with pieces ' '[{take, start, end}] to take the best part of every cut from different takes. Cut only where the takes cut, ' diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 61cd3284..8e45ffa9 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -207,6 +207,11 @@ def load(self): raise ValueError('Invalid child receipt') if not isinstance(record.get('submitting', []), list): raise ValueError('Invalid submission marker') + # Films finished before projects were made keep only their MP4: an update opens no old film. + if 'projects' not in record and state['outputs']: + record['projects'] = {o['job']: dict(id=None, done=True) for o in state['outputs']} + if not isinstance(record.get('projects', {}), dict): + raise ValueError('Invalid project links') production.view(state) try: checked = production.plan(request, fields=False, single_seconds=math.inf) # saved: the limit held when it was accepted @@ -705,6 +710,9 @@ def contextual(error, join=False): ref = record['state']['current_join'] join = next(j for j in record['state']['joins'] if j['ref'] == ref) value['join'].update(ref=ref, task=tasks.get(ref), error=contextual(join['error'], True)) + projects = record.get('projects') or {} + for output in value['outputs']: + output['project_id'] = (projects.get(output['job']) or {}).get('id') value['stalled'] = errors.with_reason(deepcopy(record.get('stalled'))) value['progress'] = self._progress(record) value['over_budget'] = self._elapsed_budget(value['progress']) @@ -937,8 +945,88 @@ def _advance(self, identity): self._bump(draft) # Wake since/wait readers when the elapsed reminder appears or clears. if draft != record: self._save(draft) + self._projects(identity) self._inspect_takes(identity) + def _projects(self, identity): + """Open every finished film as an editing project too, cut at its shots; the MP4 stays as it was. + + One try per film: a computer whose editor cannot run (or any other failure) keeps only the MP4.""" + record = self._record(identity) + for output in record['state']['outputs']: + join = output['job'] + if (record.get('projects') or {}).get(join, {}).get('done') or not output.get('library_id'): + continue + try: + project = self._project(record, output) + saved = dict(id=project['id'], done=True) + except Exception as error: # noqa: BLE001 - the film is finished either way + logging.getLogger(__name__).warning('A finished film was not opened as a project (%s)', + getattr(error, 'code', type(error).__name__)) + saved = dict(id=None, done=True) + draft = deepcopy(self._record(identity)) + draft.setdefault('projects', {})[join] = saved + self._bump(draft) + self._save(draft) + record = self._record(identity) + + def _project(self, record, output): + """Create (or find again by its key) the project of one output and cut it where each shot begins.""" + from .. import comfy_editor + projects, state, join = self.jobs.projects, record['state'], output['job'] + source = 'work:' + output['library_id'] + info = comfy_editor.sources(projects.root, [source])[source] + if info['missing'] or not info['frames']: + raise JobError('source_missing', 'The finished film is not available.') + name = (state.get('title') or '').strip() or state['id'] + project = projects.create(dict(project=dict(name=name, width=info['width'], height=info['height'])), + ref='film:' + join)['project'] + def picture(): + return sorted((c for c in project['tracks'][0]['clips']), key=lambda c: c['start']) + def edit(operations): + return projects.operations(project['id'], dict(revision=project['revision'], operations=operations))['project'] + if not picture(): + project = edit([dict(op='insert', source=source, at=0)]) + shots = self._shot_frames(record, join, info['frames']) + if len(picture()) == 1 and len(shots) > 1: + clip = picture()[0] + # From the last cut back: the left part keeps its id, so each cut still names the same clip. + cuts = [f for f, _ in shots[1:] if clip['start'] < f < clip['start'] + clip['frames']] + if cuts: + project = edit([dict(op='split', clip_ids=[clip['id']], frame=f) for f in reversed(cuts)]) + notes = [dict(op='note', clip_ids=[c['id']], text=text) + for c, (_, text) in zip(picture(), shots) if text and not c.get('note')] + if notes and len(picture()) == len(shots): + project = edit(notes) + return project + + def _shot_frames(self, record, join, frames): + """[(first frame, note)] of each shot in the joined film, from the assembly's own clip list. + + A crossfade is cut in its middle. Without that list the film is one part with no note.""" + state = record['state'] + saved = next((j for j in state['joins'] if j['ref'] == join), None) + result = (self._children(record).get(join) or {}).get('result') or {} + clips = result.get('clips') if isinstance(result, dict) else None + if saved is None or not isinstance(clips, list): + return [(0, '')] + counts = [len((saved.get('pieces') or {}).get(shot['id']) or ()) or 1 for shot in state['shots']] + if sum(counts) != len(clips) or len(counts) != len(saved['takes']): + return [(0, '')] + starts = [round(float(clip.get('output_start') or 0) * 24) for clip in clips] + rows, index = [], 0 + for shot, count in zip(state['screenplay']['shots'], counts): + first = starts[index] + if index: # where the previous clip still shows: half of that overlap goes to each side + first += max(0, starts[index - 1] + int(clips[index - 1].get('frames') or 0) - first) // 2 + if not 0 <= first < frames: + return [(0, '')] + # The screenplay as last reshot; a note is one line of at most 120 characters. + text = ' '.join(str(shot.get('action') or shot.get('idea') or '').split()) + rows.append((first, text[:120])) + index += count + return rows + def _inspect_takes(self, identity): """Start the picture and sound check of each finished take that has none; it runs off the lock. diff --git a/freevideo_engine/agent/skill/freevideo/references/film.txt b/freevideo_engine/agent/skill/freevideo/references/film.txt index 0fe5c772..29c1acc3 100644 --- a/freevideo_engine/agent/skill/freevideo/references/film.txt +++ b/freevideo_engine/agent/skill/freevideo/references/film.txt @@ -8,5 +8,6 @@ Use this when the piece runs longer than about 20 seconds, changes place or cost 4. For each shot whose takes are done: `freevideo_drama_sheet` shows timed frames and the previous chosen last frame. Look, then `freevideo_drama_choose`. A continuation can only start after the previous shot is chosen. 5. A take that missed: `freevideo_drama_reshoot` with a short note on what to change, then review the sheet again. 6. When every shot is chosen: `freevideo_drama_finish`, then read the film until its output is ready. +7. The output's `project_id` is the film opened as an editing project, one part per shot. Finish the sound there with `freevideo_project_finish_sound` (see references/edit.txt); the MP4 stays as it was. Only the user may pause or resume a film. If it is paused, stop acting and long-wait. From 2853f572007b0c68cc17da896a35152bf8d03b7a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 04:28:45 +0000 Subject: [PATCH 32/43] A screenplay carries its outline and the blocking of each scene A screenplay carries its outline and the blocking of each scene; the start confirmation lists the outline --- freevideo_engine/agent/drama.py | 39 +++++++++++++++++-- freevideo_engine/agent/guide.py | 3 ++ freevideo_engine/agent/production.py | 2 +- freevideo_engine/agent/productions.py | 9 ++++- freevideo_engine/agent/schema.py | 5 ++- freevideo_engine/agent/sessions.py | 4 +- .../agent/skill/freevideo/SKILL.txt | 2 +- .../freevideo/references/film-script.txt | 2 + web/autopilot_chat.css | 3 ++ web/autopilot_chat.js | 18 ++++++++- 10 files changed, 77 insertions(+), 10 deletions(-) diff --git a/freevideo_engine/agent/drama.py b/freevideo_engine/agent/drama.py index 56243c7a..41985aa5 100644 --- a/freevideo_engine/agent/drama.py +++ b/freevideo_engine/agent/drama.py @@ -65,6 +65,7 @@ def __init__(self, problems): FACE_ORDER = ('face', 'front', 'three_quarter') # the one picture standing for a person, then any other label, in card order MAX_VOICES = 3 # voice clips per shot, the model limit MAX_REFERENCES = 32 +MAX_BEATS = 24 # beats of a screenplay outline def check(play, single_seconds=BLOCK_SECONDS): @@ -117,6 +118,30 @@ def validate(function, *args, **kwargs): else: for key, text in play['continuity'].items(): validate(specs._text, text, '/continuity/' + str(key).replace('~', '~0').replace('/', '~1'), 20000, empty=True) + if 'outline' in play: + # The story the user confirms before shooting: beats in order, each what happens and why. + outline = play['outline'] + beats = outline.get('beats') if isinstance(outline, dict) else None + if not isinstance(beats, list) or not 1 <= len(beats) <= MAX_BEATS or set(outline) - {'beats'}: + problem('/outline', 'must be {"beats": [{"what", "why"}]} with 1 to %d beats' % MAX_BEATS) + else: + for j, beat in enumerate(beats): + path = '/outline/beats/%d' % j + if not isinstance(beat, dict) or set(beat) - {'what', 'why'} or not isinstance(beat.get('what'), str): + problem(path, 'must be {"what": what happens, "why": what causes it}') + continue + validate(specs._text, beat['what'], path + '/what', 500) + if 'why' in beat: + validate(specs._text, beat['why'], path + '/why', 500, empty=True) + scenes = play.get('blocking', {}) + if not isinstance(scenes, dict): + problem('/blocking', 'must be an object of text by scene name') + scenes = {} + for key, text in scenes.items(): + path = '/blocking/' + str(key).replace('~', '~0').replace('/', '~1') + if not isinstance(key, str) or not specs.SLUG.fullmatch(key): + problem(path, 'scene names must be short names') + validate(specs._text, text, path, 20000, empty=True) if 'captions' in play: validate(specs._bool, play['captions'], '/captions') if 'quality' in play and (not isinstance(play['quality'], str) or play['quality'] not in specs.QUALITY): @@ -150,7 +175,9 @@ def validate(function, *args, **kwargs): problem(path + '/continue', '/shots/0 cannot continue a previous shot') if 'continue' in shot: validate(specs._bool, shot['continue'], path + '/continue') - for field in ('action', 'idea', 'prompt', 'camera', 'framing', 'sound', 'music'): + if 'scene' in shot and (not isinstance(shot['scene'], str) or shot['scene'] not in scenes): + problem(path + '/scene', 'must name a scene of blocking') + for field in ('action', 'idea', 'prompt', 'camera', 'framing', 'sound', 'music', 'blocking'): if field in shot: validate(specs._text, shot[field], path + '/' + field, 20000, empty=True) if 'continuity' in shot: @@ -351,6 +378,12 @@ def continuity(): rows.append(table['set'].strip().rstrip('.') + '.') return ['Continuity - ' + ' '.join(rows)] if rows else [] + def blocking(): + """Who stands where in this shot's scene; a shot's own blocking replaces its scene's for this shot only.""" + scenes = play.get('blocking') if isinstance(play.get('blocking'), dict) else {} + text = shot.get('blocking') if isinstance(shot.get('blocking'), str) and shot['blocking'].strip() else scenes.get(shot.get('scene')) + return ['Blocking - ' + text.strip().rstrip('.') + '.'] if isinstance(text, str) and text.strip() else [] + cuts = shot.get('cuts') if cuts: # Numbered cuts inside one generation: [Shot 1] has no time, the others say when they start. @@ -362,7 +395,7 @@ def continuity(): row += [lead + '.' if lead else ''] if shot.get('continue'): row.append('Continuing from the opening frame, the scene keeps its exact layout, lighting and characters.') - row += looks() + continuity() + row += looks() + continuity() + blocking() elif cut.get('framing'): row.append(cut['framing'].strip().rstrip('.') + '.') if number == 1 and cut.get('framing'): @@ -380,7 +413,7 @@ def continuity(): parts = ['[Shot 1] ' + (lead + '.' if lead else '')] if shot.get('continue'): parts.append('Continuing from the opening frame, the scene keeps its exact layout, lighting and characters.') - parts += looks() + continuity() + parts += looks() + continuity() + blocking() parts.append(named(play, people, shot.get('action', ''))) if shot.get('camera'): parts.append(shot['camera']) diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 5d9942a6..cee42e24 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -197,6 +197,9 @@ 'job (30 s or more, or beyond the device limit, continues and joins automatically); to polish an idea first, run enhance, then generate with "prompt": {"from_job": ""} rather than retyping the text. ' 'A film is cut into shots: see the rule "Cut by default" before making one long continued video.', 'Use drama plan → start → get → sheet → choose/reshoot → finish; inspect todo after every change.', + 'Write the story the user agreed on into the screenplay as outline {beats: [{what, why}]}; the start confirmation shows the beats to the user. ' + 'Write where people stand once a scene in blocking {scene: who is left, right or centre, sitting or standing, facing whom, where the camera is} ' + 'and give each shot its scene; a shot that changes it writes its own blocking. Blocking and continuity go into every shot prompt.', 'Each finished output also gives project_id: the film opened as an editing project, one part per shot with its action as the note. ' 'Finish the sound or change the cut on that project (projects get, then finish_sound); the MP4 in library_id stays as it was. ' 'project_id is null where editing is not available.', diff --git a/freevideo_engine/agent/production.py b/freevideo_engine/agent/production.py index 73f2a1f7..0c19c235 100644 --- a/freevideo_engine/agent/production.py +++ b/freevideo_engine/agent/production.py @@ -73,7 +73,7 @@ def _compiled(play, shot): # What a screenplay shot and a line may say; anything else would be ignored without a word. SHOT_FIELDS = ('id', 'seconds', 'action', 'idea', 'prompt', 'camera', 'framing', 'sound', 'music', 'takes', 'seed', - 'continue', 'first', 'references', 'cast', 'lines', 'cuts', 'continuity') + 'continue', 'first', 'references', 'cast', 'lines', 'cuts', 'continuity', 'scene', 'blocking') LINE_FIELDS = ('who', 'line', 'how', 'language') diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 8e45ffa9..53072611 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -45,6 +45,12 @@ def _quoted(title): return '"%s"' % title if title else 'the film' +def _beats(play): + """The outline the user confirms before shooting: what happens in each beat, one line each; none without an outline.""" + outline = play.get('outline') if isinstance(play.get('outline'), dict) else {} + return [' '.join(beat['what'].split()) for beat in outline.get('beats') or []] or None + + def _detail(value): """Domain details carry identifiers only; anything else is not repeated.""" if value is None or type(value) in (bool, int): @@ -174,7 +180,7 @@ def __init__(self, jobs): self.changed = threading.Condition(jobs.lock) self._waiters = {} self._waiter_lock = threading.Lock() - # gate(action, *, by, estimate_seconds, refs, question) raises when the user must approve first. + # gate(action, *, by, estimate_seconds, refs, question, shape, beats) raises when the user must approve first. self.gate = None # transcribe(path) -> heard text; a machine without a speech model gets a note instead of a comparison. self.transcribe = take_qa.recognise @@ -666,6 +672,7 @@ def start(self, request, *, by='agent', ref=None, local=True, client=None): self.gate('start', by=by, estimate_seconds=self._progress(record)['estimate_seconds'], shape=self._shape(state), refs={}, # the film gets its ID only when the approved call repeats + beats=_beats(state['screenplay']), question='Start shooting %s (%d shots)?' % (_quoted(state['title']), len(state['shots']))) result = self._view(record) if ref: diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index ea16fad3..26e25c7c 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -703,7 +703,9 @@ def route(summary, body=None, query=None, **kw): 'characters: {key: {card: cast card ID, or name, look, voice, language}}; ' 'shots: [{id, seconds, action or prompt, camera, framing, sound, music (N/A unless the user asks for music in this shot), takes, seed, ' 'cast: [character keys], lines: [{who: character key, line, how}], ' - 'continue: true to carry on from the previous shot}]; also title, style, aspect, quality; a whole-film score is not written here but imported onto a sound track.')} + 'continue: true to carry on from the previous shot, scene: a key of blocking, blocking: this shot\'s own blocking, continuity: {character key or "set": this shot\'s own row}}]; ' + 'outline: {beats: [{what happens, why}]}, shown to the user before shooting; blocking: {scene: who stands or sits where, faces whom, where the camera is}; ' + 'continuity: {character key or "set": clothes, props, place}; also title, style, aspect, quality; a whole-film score is not written here but imported onto a sound track.')} PIECES = dict(type='array', minItems=1, maxItems=24, description=( @@ -1078,6 +1080,7 @@ def session_event(*, stored=False, agent_only=False): obj(dict(type={'const': 'confirm'}, id=string(12, pattern=r'^cf[0-9a-f]{10}$'), question=text, estimate_seconds=number(0), state=enum(('asked', 'approved', 'declined')), width=number(0, integer=True), height=number(0, integer=True), seconds=number(0), + beats=array(string(500), 1, 24, description='start: the outline of the film, one line a beat, as the user confirms it.'), action=enum(('start', 'reshoot')), refs=obj(dict(drama=PRODUCTION, shot=SLUG)), whitebox=obj(dict(work=SAVED, seconds=number(0), summary=text), ('work',), description='The rendered white-model preview the user is asked to approve.'), diff --git a/freevideo_engine/agent/sessions.py b/freevideo_engine/agent/sessions.py index ec5eca3b..91b55582 100644 --- a/freevideo_engine/agent/sessions.py +++ b/freevideo_engine/agent/sessions.py @@ -551,7 +551,7 @@ def update(self, values: dict) -> dict: os.replace(temporary, self.path) return value - def gate(self, action, *, by, estimate_seconds, refs, question, shape=None): + def gate(self, action, *, by, estimate_seconds, refs, question, shape=None, beats=None): sid, digest = errors.SESSION.get(), errors.CONFIRM.get() if by != 'agent' or sid is None or digest is None: return @@ -574,6 +574,8 @@ def gate(self, action, *, by, estimate_seconds, refs, question, shape=None): event['estimate_seconds'] = round(estimate_seconds) # What is about to be made, for the card the user answers; only what is known is written. event.update({key: shape[key] for key in ('width', 'height', 'seconds') if shape and shape.get(key) is not None}) + if beats: + event['beats'] = list(beats) row = self.sessions.append(sid, event, by='system') raise SessionError('needs_confirmation', 'Waiting for the user', details=[dict(confirm=row['id'], seconds=row.get('estimate_seconds'))]) diff --git a/freevideo_engine/agent/skill/freevideo/SKILL.txt b/freevideo_engine/agent/skill/freevideo/SKILL.txt index f303d1b6..ea643bfb 100644 --- a/freevideo_engine/agent/skill/freevideo/SKILL.txt +++ b/freevideo_engine/agent/skill/freevideo/SKILL.txt @@ -34,7 +34,7 @@ One generation or several shots (the recommended length decides, not the device 2. Otherwise the `freevideo_drama_*` tools, even when this computer can make 60 seconds in one run. Each shot is one generation of 15 to 20 seconds, cut inside with `[Shot N]`; a shot with a cast card or reference pictures is at most 15 seconds, and longer content goes into the next shot with continue=true; a plan that is refused for length is shortened to that limit, never forced; never write 3 to 6 second shots. When the computer's limit is below 15 seconds, use that limit. A continuous action across two beats (a kick and its catch, a handover) stays in the same shot. Move from shot to shot through the story (someone arrives, looks, answers), not by forcing the last frame onto the next shot. Independent shots go in one batch; a shot that continues the previous one waits for it. 3. When unsure, choose the drama tools. -The short version, for every model (a small model can rely on point 7 alone): (1) story outline first; (2) about 20 seconds or less in one place with up to three people is one generation, longer is the drama tools; (3) cuts of 2 to 4 seconds in `[Shot N]`; (4) six sections in order: `subject_definitions:`, `summary:`, `retention_analysis:`, `detailed_description:`, `overall_soundscape:`, `non_diegetic_music:`; (5) people tagged `` with speakers numbered (S1), (S2) by first spoken line in that video; (6) say what is there and keep negation words out; (7) with the `freevideo_drama_*` tools, fill the structure fields (characters, and per shot the cuts with `at`, `framing`, `action`, `camera`, `lines`) and leave `prompt` empty: the tool writes the six sections and the (S#) numbers itself; (8) the full example and the rest are in `references/film-script.md`. +The short version, for every model (a small model can rely on point 7 alone): (1) story outline first; (2) about 20 seconds or less in one place with up to three people is one generation, longer is the drama tools; (3) cuts of 2 to 4 seconds in `[Shot N]`; (4) six sections in order: `subject_definitions:`, `summary:`, `retention_analysis:`, `detailed_description:`, `overall_soundscape:`, `non_diegetic_music:`; (5) people tagged `` with speakers numbered (S1), (S2) by first spoken line in that video; (6) say what is there and keep negation words out; (7) with the `freevideo_drama_*` tools, fill the structure fields (characters, `outline`, `blocking`, `continuity`, and per shot its `scene` and the cuts with `at`, `framing`, `action`, `camera`, `lines`) and leave `prompt` empty: the tool writes the six sections and the (S#) numbers itself; (8) the full example and the rest are in `references/film-script.md`. Before each generation: one card per person (`freevideo_cast_list` first), clothes fixed for the whole film, a seating or standing plan for every beat, only what is seen and heard in the prompt (say what is there, never what is not), dialogue word for word, speaker numbers by order of first line in the video. Rules and examples for each of these, and the five faults to check before generating (stray lettering, small faces in wide shots, text under 14 pixels, third-party marks, voice references), are in `references/film-script.md`; the rest of the checks are in `references/film.md`. Generate two seeds of the first shot, look at them at full size, and only then continue. Whoever makes a shot looks at every take before choosing it. diff --git a/freevideo_engine/agent/skill/freevideo/references/film-script.txt b/freevideo_engine/agent/skill/freevideo/references/film-script.txt index 26a1930c..5a53890a 100644 --- a/freevideo_engine/agent/skill/freevideo/references/film-script.txt +++ b/freevideo_engine/agent/skill/freevideo/references/film-script.txt @@ -9,6 +9,7 @@ First version. Each part gives the few rules that matter most and one example. E - Dialogue is short and carries the conflict: a person asks, refuses, lies or gives in. No line explains what the picture already shows. - Amount of speech (estimate, to be measured): about 2 spoken words per second in English and about 4 characters per second in Chinese, counting pauses. A 20 second generation holds about 30 words, three to five lines. More than that gets rushed or cut off. - The last beat of a generation ends on a look, a door or a held line, so the next shot has something to start from. +- With the drama tools, the page goes into the screenplay as `outline`: `{"beats": [{"what": ..., "why": ...}]}`, one beat each, at most 24. The user sees the beats again when asked to start shooting. Example, a 20 second piece in one generation: a girl wants the last seat on the night bus. Cut 1: she runs, the doors are closing. Cut 2: a man in the seat sees her and pulls his bag onto it. Cut 3: she says "Please." Cut 4: he says nothing, then moves the bag. Cut 5: she sits; the bus pulls away. Two spoken lines in all, about four words. @@ -17,6 +18,7 @@ Example, a 20 second piece in one generation: a girl wants the last seat on the - Write a plan for every beat: who sits or stands where, who faces whom, where the camera is. Left and right are the camera's left and right. - Two people who talk keep the same sides in every cut; the camera stays on one side of the line between them. - Name the camera for each cut: wide, over the shoulder, close-up, and whether it moves. +- With the drama tools, write each scene's plan once in the screenplay's `blocking` (`{"kitchen": "Mia stands at the sink, ..."}`) and give every shot in that scene `"scene": "kitchen"`; the tool writes the plan into each of those prompts. A shot where people have moved writes its own `blocking`, which replaces the scene's for that shot only. Example: Beat 2, the kitchen. Mia stands at the sink, left of frame, back to the window. Dad sits at the table, right of frame, facing her. Camera behind Dad's shoulder, locked off. Cut to a close-up of Mia from his side, still on the left. diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index d17bcbb3..6d01f6d3 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -102,6 +102,9 @@ .fv-studio .fv-chat-stop:disabled{opacity:1;border-color:transparent;background:none;color:var(--fv-muted)}.fv-studio .fv-chat-stop:disabled::before{display:none} .fv-chat-confirm{box-sizing:border-box;justify-self:start;width:100%;max-width:440px;display:grid;gap:12px;padding:14px 14px 12px;border:1px solid var(--fv-border);border-radius:14px;background:var(--fv-card)} .fv-chat-confirm>p:first-child{font-size:14.5px;line-height:1.55;overflow-wrap:anywhere}.fv-chat-confirm>.fv-chat-meta{margin-top:-6px} +.fv-chat-beats{margin:0;padding:0 0 0 1.7em;display:grid;gap:4px;font-size:13.5px;line-height:1.5;overflow-wrap:anywhere}.fv-chat-beats>li::marker{color:var(--fv-muted);font-variant-numeric:tabular-nums} +.fv-chat-beats>.fv-chat-beats-more{list-style:none} +.fv-studio .fv-chat-beats .fv-chat-link{min-height:0;padding:0;border:0;background:none;color:var(--fv-accent);font-size:13px;cursor:pointer}.fv-studio .fv-chat-beats .fv-chat-link:hover{text-decoration:underline} .fv-chat-confirm[data-state=answered]{width:auto;padding:0;border:0;background:none;color:var(--fv-muted);font-size:13.5px} /* Whitebox confirmations retain their preview when answered. */ .fv-chat-whitebox-card{color:var(--fv-ink)} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index a3c739e9..153eb47e 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -1268,16 +1268,19 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () playing(); }}; } + // The outline of a film about to be shot, one line a beat; past eight the rest fold under one line. + const BEATS_SHOWN = 8; function confirmCard() { const item = el('li', 'fv-chat-confirm'); let shownState = null; let whitebox = null; + let allBeats = false; return {element: item, update(entry) { if (entry.whitebox) { whitebox ||= whiteboxCard(item); whitebox.update(entry); return; } const session = state.session, key = cardKey(session, entry.id); const value = answers.get(key) || state.confirms[entry.id] || entry.state || 'asked'; const disabled = confirming.has(key) || !inSelectedChat() || state.agent.connected !== true, error = confirmErrors.get(key) || ''; - const signature = JSON.stringify([value, disabled, error]); + const signature = JSON.stringify([value, disabled, error, allBeats]); if (signature === shownState) return; shownState = signature; item.dataset.state = value === 'asked' ? 'asked' : 'answered'; if (value !== 'asked') { item.replaceChildren(el('span', null, value === 'approved' ? t('Started', '已开始') : t('Not now', '先不拍'))); return; } @@ -1300,7 +1303,18 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const estimate = el('p', 'fv-chat-meta', entry.estimate_seconds > 0 ? about(entry.estimate_seconds, t) : ''); estimate.hidden = !estimate.textContent; // What is about to be made, written as on the job card; a question that carries no size or length shows only the time. const shape = el('p', 'fv-chat-meta', jobMeta(entry, t)); shape.hidden = !shape.textContent; - item.replaceChildren(question, shape, estimate, buttons); + const beats = Array.isArray(entry.beats) ? entry.beats : []; + const outline = el('ol', 'fv-chat-beats'); + const folded = beats.length > BEATS_SHOWN && !allBeats; + for (const beat of folded ? beats.slice(0, BEATS_SHOWN - 1) : beats) outline.append(el('li', null, beat)); + if (folded) { + const rest = beats.length - BEATS_SHOWN + 1; + const more = el('li', 'fv-chat-beats-more'); + more.append(press('fv-chat-link', t(`${rest} more`, `另外 ${rest} 拍`), () => { allBeats = true; shownState = null; render(); })); + outline.append(more); + } + outline.hidden = !beats.length; + item.replaceChildren(question, shape, estimate, outline, buttons); if (error) { const notice = el('p', 'fv-chat-error', error); notice.setAttribute('role', 'alert'); item.append(notice, issueButton(t, () => ({where: CHAT_ISSUE, message: error}))); From cc2161f58f3ba9c3c14f6528c4d22c498b58bde9 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 04:35:15 +0000 Subject: [PATCH 33/43] Imported clips can be removed from the editor library, with a second confirmation when a project... Imported clips can be removed from the editor library, with a second confirmation when a project uses them --- freevideo_engine/comfy_editor.py | 44 ++++++++++++++++++++- web/editor.css | 12 ++++++ web/editor_icons.js | 1 + web/editor_library.js | 68 ++++++++++++++++++++++++++++++++ web/editor_store.js | 6 +++ 5 files changed, 129 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py index b62e98c3..1bb9b152 100644 --- a/freevideo_engine/comfy_editor.py +++ b/freevideo_engine/comfy_editor.py @@ -73,9 +73,10 @@ class TooLarge(ValueError): class DeleteRefused(ValueError): - def __init__(self, reason): + def __init__(self, reason, users=None): super().__init__(reason) self.reason = reason + self.users = users def _whole(value, low=0, high=2**53 - 1): @@ -895,6 +896,39 @@ def list_imports(root, unreadable=None): return [row for _, _, row in sorted(rows, key=lambda item: item[:2], reverse=True)] +def _import_users(root, identity): + """How many saved edits put this import on a timeline, counting the clips an applied change replaced.""" + source = dict(kind='import', id=identity) + users = 0 + for _, _, record, _ in _projects(root): + tracks = [*record['tracks'], *(track for tx in record.get('replacements', []) for track in [*tx['originals'], *tx['clips']])] + if any(clip.get('source') == source for track in tracks for clip in track['clips']): + users += 1 + return users + + +def delete_import(root, identity, confirm=False): + """Remove the copy FreeVideo made of an imported file, never the file it was copied from. + An import that is still being prepared, or that an edit uses (unless confirmed), stays.""" + with _STATE: + path = _import_path(root, identity) + folder = path.parent + if not folder.exists(): + return + try: + value = _read_import(root, identity) + except (OSError, ValueError): + value = None # A damaged record can still be removed. + if value is not None and value['state'] == 'preparing': + raise DeleteRefused('import_in_use') + users = _import_users(root, identity) + if users and confirm is not True: + raise DeleteRefused('import_referenced', users) + # _import_path rejects links in the owned path; rmtree does not follow links inside it. + shutil.rmtree(folder) + _FAILED_IMPORTS.pop((str(Path(root).resolve()), identity), None) + + def _display_name(value): if not isinstance(value, str) or not value or re.search(r'%(?![0-9a-fA-F]{2})', value): raise ValueError('Invalid import filename') @@ -1333,7 +1367,7 @@ async def guarded(request): try: return await handler(request) except DeleteRefused as error: - return web.json_response(dict(error='Cannot delete this edit', reason=error.reason), status=409, headers=_HEADERS) + return web.json_response(dict(error='Cannot delete this edit', reason=error.reason, **({'users': error.users} if error.users else {})), status=409, headers=_HEADERS) except Conflict as error: return web.json_response(dict(revision=error.revision, **({'id': error.identity} if error.identity else {})), status=409, headers=_HEADERS) except TooLarge: @@ -1439,6 +1473,12 @@ async def upload(request): manager.submit(value['id']) return web.json_response({'import': value}, headers=_HEADERS) + @endpoint('post', '/freevideo/editor/import/delete') + async def remove_import(request): + body = await _body(request) + await asyncio.to_thread(delete_import, root(), body.get('id'), body.get('confirm') is True) + return web.json_response(dict(deleted=True), headers=_HEADERS) + @endpoint('get', '/freevideo/editor/imports') async def imports(request): unreadable = [] diff --git a/web/editor.css b/web/editor.css index e043eba2..c5f9aead 100644 --- a/web/editor.css +++ b/web/editor.css @@ -39,6 +39,18 @@ .fv-ed-source-time,.fv-ed-source-used{position:absolute;right:4px;bottom:4px;border-radius:3px;background:#0009;padding:1px 4px;color:#fff;font-size:11px;font-variant-numeric:tabular-nums} .fv-ed-source-used{right:auto;left:4px} .fv-editor .fv-ed-source-add{position:absolute;top:4px;right:4px;width:24px;height:24px;min-height:24px;padding:0;border:0;border-radius:3px;background:#0009;color:#fff;font-size:18px;line-height:24px;opacity:0;pointer-events:none;cursor:pointer} +.fv-editor .fv-ed-source-remove{position:absolute;top:4px;left:4px;display:flex;align-items:center;justify-content:center;width:24px;height:24px;min-height:24px;padding:0;border:0;border-radius:3px;background:#0009;color:#fff;opacity:0;pointer-events:none;cursor:pointer} +.fv-ed-source-card:hover .fv-ed-source-remove,.fv-ed-source-card:focus-within .fv-ed-source-remove{opacity:1;pointer-events:auto} +.fv-ed-source-card[data-asking] .fv-ed-source-add,.fv-ed-source-card[data-asking] .fv-ed-source-remove,.fv-ed-source-card[data-asking] .fv-ed-source-time,.fv-ed-source-card[data-asking] .fv-ed-source-used{visibility:hidden} +.fv-editor .fv-ed-source-confirm{position:absolute;inset:0;display:grid;grid-template-rows:auto auto 22px;align-content:center;gap:3px;padding:4px;border-radius:inherit;background:var(--fv-raised,#212a37);color:var(--fv-ink,#eee);text-align:left;z-index:2} +.fv-editor .fv-ed-source-confirm strong{font-size:12px;font-weight:400;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.fv-editor .fv-ed-source-confirm small{font-size:11px;line-height:1.3;color:var(--fv-muted,#a5b3c6);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.fv-editor .fv-ed-source-confirm .fv-ed-source-confirm-problem{color:var(--fv-danger,#f09aa2)} +.fv-editor .fv-ed-source-confirm-actions{display:flex;gap:4px} +.fv-editor .fv-ed-source-confirm-actions button{flex:1 1 0;min-width:0;height:22px;min-height:22px;padding:0;border:1px solid color-mix(in srgb,var(--fv-ink,#eee) 10%,transparent);border-radius:5px;background:color-mix(in srgb,var(--fv-ink,#eee) 6%,transparent);color:var(--fv-ink,#eee);font-size:12px;line-height:1;cursor:pointer} +.fv-editor .fv-ed-source-confirm-actions button:focus-visible{outline:2px solid color-mix(in srgb,var(--fv-accent,#6db8fa) 70%,transparent);outline-offset:1px} +.fv-editor .fv-ed-source-confirm-actions .fv-ed-source-confirm-yes{border-color:transparent;background:var(--fv-danger-subtle,#30212a);color:var(--fv-danger,#f09aa2)} +.fv-editor .fv-ed-source-confirm-actions button:disabled{opacity:.65;cursor:default} .fv-ed-source-card:hover .fv-ed-source-add:not(:disabled),.fv-ed-source-card:focus-within .fv-ed-source-add:not(:disabled){opacity:1;pointer-events:auto} .fv-ed-source-preparing{font-size:12px;color:var(--fv-muted);padding:6px;white-space:normal} .fv-ed-audio-icon{font-size:26px;color:var(--fv-muted)} diff --git a/web/editor_icons.js b/web/editor_icons.js index 4bdb51fa..91758f0d 100644 --- a/web/editor_icons.js +++ b/web/editor_icons.js @@ -3,6 +3,7 @@ const paths = { adjust: 'M3 1v14M8 1v14M13 1v14M1 5h4M6 11h4M11 5h4', fit: 'M1 6V1h5M10 1h5v5M15 10v5h-5M6 15H1v-5', check: 'm3 8 3 3 7-7', + trash: 'M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.1a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9l.6-8.1M7 7v4M9 7v4', }; export function editorIcon(name) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); diff --git a/web/editor_library.js b/web/editor_library.js index 2ad1d9ef..61344a8f 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -24,6 +24,8 @@ export function createEditorLibrary(host, {store, t, actions, state}) { let listRead = Promise.resolve(), refreshTask = null, retryTimer = null, finishRetry = null, refreshAgain = false; const expectedOutputs = new Set(); const uploading = new Map(), recentImports = new Map(), pendingAdds = new Map(); + const removal = new Map(); // import id → {users, busy, failed}: the clips whose deletion is being asked about + let focusAfter = null; // {cancel: id} | {card: key} | {next: key}: where focus goes once the list is drawn again const preparing = () => t('Preparing this clip. It goes on the timeline when ready.', '素材准备好后会自动放到时间线上。'); const cannotOpen = () => t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; // Why an import did not work, and where to report it: only a file that could not be read lists the formats and asks for a report. @@ -107,9 +109,59 @@ export function createEditorLibrary(host, {store, t, actions, state}) { plus.ondblclick = event => { event.preventDefault(); event.stopPropagation(); }; plus.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') insert(event); }; container.append(button, plus); + if (kind === 'import' && (row.state === 'ready' || row.state === 'failed')) { + const remove = el('button', 'fv-ed-source-remove'); remove.type = 'button'; remove.append(editorIcon('trash')); + remove.title = t('Delete', '删除'); remove.setAttribute('aria-label', `${remove.title}: ${label}`); + remove.onclick = event => { + event.preventDefault(); event.stopPropagation(); + if (!removal.has(row.id)) { removal.set(row.id, {users: 0, busy: false, failed: false}); focusAfter = {cancel: row.id}; render(); } + }; + container.append(remove); + const asking = removal.get(row.id); + if (asking) { container.dataset.asking = 'true'; container.append(removeStrip(row, asking)); } + } if (kind !== 'work' && row.state === 'failed' && row.reason !== 'disk_full') container.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: t('Cannot open', '打不开'), kind: row.detail || null}))); return container; } + // Asked inside the card, so a list that redraws keeps the question. Only FreeVideo's copy goes; the original file stays where it is. + function removeStrip(row, asking) { + const strip = el('div', 'fv-ed-source-confirm'); strip.setAttribute('role', 'group'); strip.dataset.id = row.id; + strip.setAttribute('aria-label', t('Delete this clip', '删除这个素材')); + const users = asking.users, hint = el('small', asking.failed && !asking.busy ? 'fv-ed-source-confirm-problem' : null, + asking.busy ? t('Deleting…', '正在删除…') : asking.failed ? t('Could not delete', '删除失败,请重试') + : users ? t(`Used in ${users} ${users === 1 ? 'edit' : 'edits'}`, `已用于 ${users} 个剪辑`) : t('Original file kept', '原文件保留')); + const buttons = el('div', 'fv-ed-source-confirm-actions'); + const no = el('button', 'fv-ed-source-confirm-no', t('Cancel', '取消')), yes = el('button', 'fv-ed-source-confirm-yes', t('Delete', '删除')); + for (const control of [no, yes]) { control.type = 'button'; control.disabled = asking.busy; } + const cancel = () => { removal.delete(row.id); focusAfter = {card: `import:${row.id}`}; render(); }; + yes.onclick = event => { event.stopPropagation(); removeImport(row); }; + no.onclick = event => { event.stopPropagation(); cancel(); }; + // The editor hands a key to the control's own handler, as for the tabs above; Escape closes only the question. + strip.onkeydown = event => { if (event.key === 'Escape' && !asking.busy) { event.preventDefault(); event.stopPropagation(); cancel(); } }; + buttons.append(no, yes); + strip.append(el('strong', null, t('Delete this clip?', '删除这个素材?')), hint, buttons); + return strip; + } + async function removeImport(row) { + const asking = removal.get(row.id); + if (!asking || asking.busy || disposed) return; + asking.busy = true; asking.failed = false; render(); + try { + await store.removeImport(row.id, {confirm: asking.users > 0}); + // Focus moves to the next clip, or to Import when it was the last. + const keys = [...grid.querySelectorAll('.fv-ed-source')].map(card => card.dataset.key), at = keys.indexOf(`import:${row.id}`); + focusAfter = {next: keys[at + 1] || null}; + removal.delete(row.id); recentImports.delete(row.id); pendingAdds.delete(`import:${row.id}`); + imported = imported.filter(item => item.id !== row.id); + } catch (error) { + asking.busy = false; + if (error?.reason === 'import_referenced') asking.users = Math.max(1, Number(error.response?.users) || 1); + else if (error?.reason === 'import_in_use') { + removal.delete(row.id); focusAfter = {card: `import:${row.id}`}; + actions.notify?.(t('This clip is still being prepared. Try again in a moment.', '素材还在处理,请稍后再试。')); + } else asking.failed = true; + } finally { render(); } + } function updateUsed() { const keys = new Set((state?.record.tracks || []).flatMap(track => track.clips) .filter(clip => clip.source).map(clip => sourceKey(clip.source))); @@ -123,6 +175,12 @@ export function createEditorLibrary(host, {store, t, actions, state}) { works.setAttribute('aria-selected', String(tab === 'work')); imports.setAttribute('aria-selected', String(tab === 'import')); works.setAttribute('aria-pressed', String(tab === 'work')); imports.setAttribute('aria-pressed', String(tab === 'import')); search.hidden = tab !== 'work'; + // A redraw (the imports are read again every few seconds) keeps the focus on the same control. + const held = document.activeElement, strip = held && grid.contains?.(held) ? held.closest?.('.fv-ed-source-confirm') : null; + const control = ['fv-ed-source-remove', 'fv-ed-source-add'].find(name => held?.className === name); + const owner = control ? held.closest?.('.fv-ed-source-card')?.querySelector('.fv-ed-source')?.dataset.key : null; + const keep = !held || !grid.contains?.(held) ? null : strip ? {button: held.className, id: strip.dataset.id} + : owner ? {control, card: owner} : held.dataset?.key ? {card: held.dataset.key} : held.className === 'fv-ed-import' ? {add: true} : null; grid.replaceChildren(); if (tab === 'import') { const add = el('button', 'fv-ed-import', t('Import', '导入')); add.type = 'button'; add.onclick = () => file.click(); grid.append(add); @@ -139,6 +197,16 @@ export function createEditorLibrary(host, {store, t, actions, state}) { } const unread = tab === 'import' ? importUnread : 0; unreadable.textContent = unread ? t(`${unread} imported ${unread === 1 ? 'file' : 'files'} could not be read`, `另有 ${unread} 个导入的文件无法读取`) : ''; + if (!focusAfter && keep) focusAfter = keep.button ? {again: keep} : keep.control ? {control: keep} : keep.card ? {card: keep.card} : {next: null}; + if (focusAfter) { + const target = focusAfter; focusAfter = null; + const card = key => [...grid.querySelectorAll('.fv-ed-source')].find(node => node.dataset.key === key); + const node = target.control ? card(target.control.card)?.parentNode?.querySelector('.' + target.control.control) + : target.again ? [...grid.querySelectorAll('.fv-ed-source-confirm')].find(box => box.dataset.id === target.again.id)?.querySelector('.' + target.again.button) + : target.cancel ? [...grid.querySelectorAll('.fv-ed-source-confirm')].find(strip => strip.dataset.id === target.cancel)?.querySelector('.fv-ed-source-confirm-no') + : target.card ? card(target.card) : (target.next && card(target.next)) || grid.querySelector('.fv-ed-import'); + node?.focus?.(); + } if (scroll > 0) { const replacement = anchor && [...grid.querySelectorAll('.fv-ed-source')].find(node => node.dataset.key === anchor.dataset.key); host.scrollTop = replacement ? scroll + replacement.getBoundingClientRect().top - anchorTop : scroll; diff --git a/web/editor_store.js b/web/editor_store.js index bbf16bb4..250ee8b3 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -435,6 +435,12 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener return value; }, async imports() { return counted(await json('/freevideo/editor/imports', undefined, {items: []})); }, + // Removes FreeVideo's copy of an imported file. The server asks first when an edit uses it (reason import_referenced, with its count in response.users). + async removeImport(id, {confirm = false} = {}) { + const response = await request('/freevideo/editor/import/delete', post({id, confirm})); + if (!response.ok) throw await saveFailure(response); + sources.delete(`import:${id}`); + }, watchImports(fn) { return watch(() => store.imports(), items => { fn(items); return true; }); }, upload(file, progress = () => {}) { if (disposed) return Promise.reject(new Error('editor_closed')); From 0d329845d333162017600c031ec0e414d45350b5 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 04:39:23 +0000 Subject: [PATCH 34/43] Deleting an imported clip fails closed when an edit cannot be read --- freevideo_engine/comfy_editor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py index 1bb9b152..71851705 100644 --- a/freevideo_engine/comfy_editor.py +++ b/freevideo_engine/comfy_editor.py @@ -900,7 +900,8 @@ def _import_users(root, identity): """How many saved edits put this import on a timeline, counting the clips an applied change replaced.""" source = dict(kind='import', id=identity) users = 0 - for _, _, record, _ in _projects(root): + # An edit that cannot be read might use the clip: fail closed, as the other reference checks do. + for _, _, record, _ in _projects(root, strict=True): tracks = [*record['tracks'], *(track for tx in record.get('replacements', []) for track in [*tx['originals'], *tx['clips']])] if any(clip.get('source') == source for track in tracks for clip in track['clips']): users += 1 From 70b1b85f295fe28a7545b3a4c74c3ce73e759f0d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 04:45:43 +0000 Subject: [PATCH 35/43] Deleting an imported clip also asks when an edit has it only as an earlier version --- freevideo_engine/comfy_editor.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py index 71851705..3a6618e7 100644 --- a/freevideo_engine/comfy_editor.py +++ b/freevideo_engine/comfy_editor.py @@ -897,15 +897,10 @@ def list_imports(root, unreadable=None): def _import_users(root, identity): - """How many saved edits put this import on a timeline, counting the clips an applied change replaced.""" - source = dict(kind='import', id=identity) - users = 0 + """How many saved edits use this import: on a timeline, in a clip's earlier versions, or in a clip an applied change replaced.""" + key = source_key(dict(kind='import', id=identity)) # An edit that cannot be read might use the clip: fail closed, as the other reference checks do. - for _, _, record, _ in _projects(root, strict=True): - tracks = [*record['tracks'], *(track for tx in record.get('replacements', []) for track in [*tx['originals'], *tx['clips']])] - if any(clip.get('source') == source for track in tracks for clip in track['clips']): - users += 1 - return users + return sum(1 for _, _, record, _ in _projects(root, strict=True) if key in references(record)) def delete_import(root, identity, confirm=False): From a379f824f4d6f4c5fa59dd023ab63af95c0f0742 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 04:39:13 +0000 Subject: [PATCH 36/43] A film that uses the built-in director has its takes chosen and joined automatically --- freevideo_engine/agent/jobs.py | 1 + freevideo_engine/agent/productions.py | 122 ++++++++++++++++++++++++++ freevideo_engine/agent/routes.py | 1 + 3 files changed, 124 insertions(+) diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 134446b3..dfe99948 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -692,6 +692,7 @@ def retry_pending(self): self._receipts.retry() def close(self): + self.productions.close() if self._receipts is not None: self._receipts.close() diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 53072611..c696df26 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -185,6 +185,11 @@ def __init__(self, jobs): # transcribe(path) -> heard text; a machine without a speech model gets a note instead of a comparison. self.transcribe = take_qa.recognise self._inspecting = set() + # note(session, event) writes what the built-in director did into the film's conversation. + self.note = None + # The built-in director of each film runs on its own thread: {id: (thread, revision it stopped at or None)}. + self._directors = {} + self._closed = threading.Event() self.load() jobs.parent_allowed = self.allows @@ -876,6 +881,76 @@ def tick(self): with self.changed: for identity in list(self.records): self._run(identity) + self._direct(identity) + + def close(self): + """Stop the built-in directors; a question already queued is cancelled by its own wait.""" + self._closed.set() + + def _directing(self, identity): + """Whether the built-in director should be choosing for this film now.""" + record = self.records.get(identity) + if record is None or self._closed.is_set(): + return False + state = record['state'] + return (state['director'] == 'builtin' and not state.get('paused') and not state['cancelled'] + and state['status'] in ('shooting', 'needs_choice', 'ready')) + + def _direct(self, identity): + """Keep one built-in director running for a film that chose it, through pauses and restarts. + + The director keeps no state of its own: it reads the film each time, so a new thread picks up where the + last one left off. One that gave up is started again only after the film changes. + """ + if not self._directing(identity) or self.records[identity].get('stalled'): + return + thread, gave_up = self._directors.get(identity, (None, None)) + if thread is not None and thread.is_alive(): + return + if gave_up is not None and gave_up == self.records[identity]['state']['revision']: + return + thread = threading.Thread(target=self._director, args=(identity,), daemon=True, name='director-' + identity) + self._directors[identity] = (thread, None) + thread.start() + + def _noted(self, identity, step): + """One choice, reshoot or join of the built-in director as a finished step of the film's conversation.""" + session = (self.records.get(identity) or {}).get('session') + if self.note is None or not session: + return + refs = dict(drama=identity, **({'shot': step['shot']} if step.get('shot') else {})) + if step['action'] == 'choose': + chosen = next((s.get('chosen') for s in self.records[identity]['state']['shots'] if s['id'] == step['shot']), None) + if type(chosen) is int: + refs['take'] = chosen + try: + self.note(session, dict(type='step', tool='freevideo_drama_' + step['action'], summary=step['reason'], + state='done', refs=refs)) + except Exception: # noqa: BLE001 - a conversation that is gone or full never stops the film + logging.getLogger(__name__).warning('A step of the built-in director was not written to the conversation') + + def _director(self, identity): + from . import builtin_director + stop = lambda: not self._directing(identity) + ask = builtin_director.JobAsk(_JobTools(self.jobs, 'production:' + identity, stop, self._closed), stop=stop) + gave_up = None + try: + for step in builtin_director.run(_JobDirectorAdapter(self), identity, ask=ask, stop=stop): + if step['action'] == 'error': + gave_up = step['reason'] + else: + self._noted(identity, step) + except Exception as error: # noqa: BLE001 - a director that fails leaves the film to the user + gave_up = type(error).__name__ + if gave_up is not None: + logging.getLogger(__name__).warning('The built-in director stopped (%s); it starts again when the film changes', + gave_up) + with self.changed: + record = self.records.get(identity) + if gave_up is not None and record is not None: + self._directors[identity] = (None, record['state']['revision']) + else: + self._directors.pop(identity, None) def _bump(self, draft): draft['state']['revision'] += 1 @@ -1306,6 +1381,27 @@ def _on_frames(start, end, fps=24): return dict(start=a, end=b) if b > a else dict(start=start, end=end) +class _JobTools: + """The submit, wait and cancel the built-in director's questions use, on the job queue in this process.""" + def __init__(self, jobs, client, stop, closed): + self.jobs, self.client, self.stop, self.closed = jobs, client, stop, closed + + def submit(self, request): + return self.jobs.view(self.jobs.submit(request, client=self.client)[0]) + + def wait(self, identity, timeout=None): + deadline = time.monotonic() + max(0., timeout or 0.) + while True: + row = self.jobs.get(identity) + left = deadline - time.monotonic() + # A paused or cancelled film gives up its question at once, not after the next long wait. + if row['status'] in DONE or left <= 0 or self.stop() or self.closed.wait(min(.25, left)): + return self.jobs.view(row) + + def cancel(self, identity): + return self.jobs.view(self.jobs.cancel(identity)) + + class DirectorAdapter: """Synchronous in-process operations for builtin_director.run. @@ -1322,3 +1418,29 @@ def __getattr__(self, name): if name in ('plan', 'start', 'sheet', 'segments', 'choose', 'reshoot', 'finish', 'cancel', 'pause', 'resume'): return getattr(self.productions, name) raise AttributeError(name) + + +class _JobDirectorAdapter(DirectorAdapter): + """The director's operations when its questions go to the local job queue. + + A question names its pictures instead of carrying them: each contact sheet is kept under the chat images + by the digest of its bytes, so the same sheet is stored once. + """ + def sheet(self, identity, shot, *, frames=None): + from . import attachments + result = self.productions.sheet(identity, shot, frames=frames) + def stored(data, suffix): + name = hashlib.sha256(data).hexdigest() + suffix + target = attachments.path(self.productions.jobs.port.input_root, name) + if not target.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name('.sheet-' + uuid.uuid4().hex + '.tmp') + try: + temporary.write_bytes(data) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + return dict(file=name) + takes = [dict(take=t['take'], image=stored(t['image'], '.jpg')) for t in result['takes']] + previous = stored(result['previous'], '.png') if result['previous'] is not None else None + return dict(takes=takes, previous=previous) diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 9c7b5245..57f58886 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -427,6 +427,7 @@ async def off(request): # Operations an agent runs only after the user approves them; the page changes the policy. confirmations = Confirmations(port.output_root / 'FreeVideo' / '.agent' / 'confirm.json', sessions) jobs.productions.gate = confirmations.gate + jobs.productions.note = lambda sid, event: sessions.append(sid, event, by='builtin') jobs.session_open = sessions.require_open except Exception as error: # Optional agent state must never prevent the studio or other routes from loading. From 30670d5a8ad9ff13ab0e3b029b5d9640847c849d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 05:02:59 +0000 Subject: [PATCH 37/43] Tutorial: reference pictures, short-drama outline and staging, the edit of a finished short drama --- web/tutorial/drama-outline-1.webp | Bin 0 -> 16698 bytes web/tutorial/drama-outline-2.webp | Bin 0 -> 19034 bytes web/tutorial/film-project-1.webp | Bin 0 -> 73482 bytes web/tutorial/film-project-2.webp | Bin 0 -> 75150 bytes web/tutorial/image-references-1.webp | Bin 0 -> 14580 bytes web/tutorial/image-references-2.webp | Bin 0 -> 18532 bytes web/tutorial_book_content.js | 59 ++++++++++++++++++++++++++- 7 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 web/tutorial/drama-outline-1.webp create mode 100644 web/tutorial/drama-outline-2.webp create mode 100644 web/tutorial/film-project-1.webp create mode 100644 web/tutorial/film-project-2.webp create mode 100644 web/tutorial/image-references-1.webp create mode 100644 web/tutorial/image-references-2.webp diff --git a/web/tutorial/drama-outline-1.webp b/web/tutorial/drama-outline-1.webp new file mode 100644 index 0000000000000000000000000000000000000000..694631a721cd5c91b83dd8668e6044e55a47b03f GIT binary patch literal 16698 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 Z^`6EL7M?uAmZY#-LaSdO0hkn_004i#-h2Q6 literal 0 HcmV?d00001 diff --git a/web/tutorial/drama-outline-2.webp b/web/tutorial/drama-outline-2.webp new file mode 100644 index 0000000000000000000000000000000000000000..ac3cf1dd6a57b26b3e88461c45506245d2bcf1d1 GIT binary patch literal 19034 zcmaI6bC51E*YzapBqI}NhyT+5)&5gGul!fLNcVqL{Qo@n5F=4;%lNdH=)iE)FjLG>ZRWCsk#Ue{A}XDJ}jFHvT`@#KGx5`j~$j zURxX2|Mc~r^q<+mn%SwT{JTQ_E4Tn>fHFV=ApD>C|EvE?_Jsfd=Mw+`692zG;|u_x zEffI2-1uJ~X+8je5CQv@J0gM190QnbJSBJ*%pnN5OIr~*uq4v@#^Gtm0iInByKi|@{tp7& zKTG}rpEPeAuOMq6j}ISh>;5i%>v@0i`voHW>3^7haCQXdA@U&}`kr&l@B;|Sax(?~ z_AK~Y2{`d({hVC#c{sl*95HzJ&Hgm|_5Bz;y5BJ*_|<*QJqvuewhHV`?P<}dm^~(Yw=(8_vKslPrvS* z@9X~w`RsiKeo{E<+wyPziT#QGrkEFbxE-In>HGPX{!Dt)c>I~Nza#kh0SH9>V1Ivn z*+2h$rv3ap=c)df|L`3pJl)>rJ^W040{P>8JAS)=q`lt0{Ok(+{M>&keEEM-ycpc| zz4wj({QNxJq1@M9{+#~|{$&50f9?MK>`~kkob;UtH2tLh{Q0K%rxEDN`_$hOF!TTZ z$oua2x%m8HTO@Ym%$BD>i5x!oFVJ-Q^w86GqSuuNb&wd* zI^V1|5{+6^Hh`4!>>_y>XXKupPE{qfp^#=l>6%P!%Ev6mO0+LB`Y?Z+n=`QGs$7RM>tWWy*TBR z02BnU%{g&pjOqZeNtanN;G?lY* zG}A-H8jN)P!yI2cE9=N%Vb$<{;V*Q)-qb77=WuxYAFeT6Ex7CM$Vk+YK$WYG{%Oq_ z%DCZt<+Yf$z}Xn$qcDl|aiPyyByn|Sy@%d(^+w|b=k#w!O5?x@@ilh&gwoNE@WYl) zjaE-V3AncV2rj?d_kEU|hx%l1{I*|D7X;%LMuhVtNv>t{YtR+j7t|Fc3>-VJ+JH08 z?3tjCB9|5|_s#y&0fzP--0PtN9#824x~`&QclcWu94wrlZ^za^QtfwA9QkyTshhmRNK&EBx-=7+2V=t7~IN?>M;J8h&so@{ol7Dnt2Sg9s8g+%)%%ey~kG zG<2lt2q8S=h9)N3!pA4~{Za**XX032HF_|_YJg}-J|D}!nnyHc(cm{}Q)XDJAi_Is z!KdQ>oyL39l+n;YrmL=tE3gx%V=9e}UC-Aclt(s?|2~>Hn1fn80Md&DYxy;G>oZc{ zNNkmx;l{)tAtetCt4rrp{zn9kgAJXOBT1|Tg@{g3n+9Z^R-!Xt2qv+n?(1NhSHi`t z1+YX8$#D=d2oYJY9#r?{h4F_kKWD((gnk`U{EloZi?SxVD=;*`A#R>U7jePoT0I)_ zUM?k?%1<)|sXA*f!wXWmd4Zdb!}tAh!H>n#>V$>`Cx7ma(T?9kud13Lc3Mav8O}UQ3b{rekPFAG_<{41M>|eUh;^vP$pW@b$ zF8IIbpON*w!)0Ak2WKVCSL9uHmnx3_;RE5TFSFGM8Hx;m=m2QMC&Y273!) zPlZsHQ&pYTVPW*s{Sa4v&oY5NBNLEer4h}odGkMxeuB8@>MxzMxly=-_XMv_pMz;K4%w z0@lBf1RjJ4`2LAqzjYC6kMJ>9MQh6Vo+v2JzZYz20#lyDQ_dJ#uOsj6e-V#F}5R=B`7JUFK$Qb+=Re=H0kvwCHU4{G}hV-6U`Q z74yWavc@1QWYLBZtXKK$x$vuv^;Vct{kQEOkY^t=oAn8%M6b<@8eR+mJ?o|!9J;zX zhiD~s_I%5L9-SxC4&laRI$*Hc3H)6%Vp|33lBW1{x?Ui<#7idr3U^2N*c433{U>3t z;uD=W$Bvy&>$(|iY8rw#(aOqa=#!{)09Q=`{AKkM$*pe)Ka@IbeN%2xju_UntdGp}nNLQ{Tp%1b2Q&#V&E-$_~b)X7f$>SgDWq z16na6Rxg{0o2wVg<&$07jTy^fzOH^TU`rPz<=41xoJ6;0sbAey+pu|;Gr+J%t2_PL z((=!|gfqd*87%ki;W-mg@I2d{Y)-ne=@>EBI3|{d9?l-M;LW$6ug#|M9W*x zT2QYvf*xMTnEAIu=jNc4W`Q5gmK&$rrS#B=n;Agnjej#Q zF0O7Iwn><^BmI((d%CCMn<4#lYbpaW6@|*ObUM+Qh9^Mz&ixFc5>4XWUq=t2@vS&MHsudZl=`ACG?KhKBFBWG3s{sea&~fs9k1gJrNKn)qWe(>T2!8nK$3JT&y);~!F6&lvLdBGBH1Ap^v0U{7`k$~iPtBR zuA71y(rOp^=$c4)x1X;0BvZ$S7(7dxkgXrGR$JD*N%2hw`)>6@u(-3c+KOC=b5wua zvq|OJ?Q_$f8pHDa$p};pkJ3H=IfTJazPE>*$=pGi)2b3*c!$uB`(F~odNR^)6|tO3 z=Hh8^LXoLCF$)Jl4KC@bbL$#IVkSfDbxn=*;1}Wv4A!PnjR5SNY6@m6L8!vECDdwI z2mETCOmmgk^6YRC>EZ-gbOGIuP>&x9f!cs=9K3)zn)!G>MFi*Z@yTv;N|-5iDgzo~ zuZIM<%b@DNKfR7f2QBX=9zdAZd>Drw#6j@iwu<{IcXm2dVyi_s9y6J^j(E5A;RDnI zsmQCfx&p-yK!NtTlb{&ep$==vRf--{R%^g&0GAjD({SXi)KW!UnKN)7Tw`N})}l9# zMluGAyeC@v+fKVlPaUsmN|yMU2{<;Vc!0{7pqi_3Md2L5mNybsak(zakmkTXf4k03 zLtQelXo;vZsJldXmL8=uJ`~;q0{7;0v`0eh_>A_>VtoMmC~Q&p36Z>TKn zbO0m)qZC3;KcM^E?J#BX0`t5$C1MWN+f#mtLw1-sp`zy5S|BJO}qrq}rE zk0myEjW!>)MzMF$yCG*F;1iWd#q07Xbzmz*6m8f{ZuT{pH5~-BLGuH?W6a> zORxYqKF*9m2P_QVRmI)um*zJ2Rbl50qe_sU=q&Ze=E$()*I)>g>4R+cSj9CPGnp@o z*I}zY;57H3*5SMv?)29oJC@F&B|+Outo(0Twy=1;bi+y>C=IbFby5_{ zi0aLKR5oo^=;BPpL@vkndseh-U;8x%i2EzD=qEcXh3~CTt%Qc!yF)AE9Xf zu&_f`Uq?=b({5W#V6?BvY;1P)S`z9=w=Ofu*`eW-`Eb9&0elI$li|eiQQ?X-H)pHV zT+JOT-2Jj`#y+5*OFoW<51q>-R+=GGiqcdhRS#kst8IKOTpUqmi0! z7}2X}ieldz178#(by#nGh5y6cF-nfbZz2g zp8WBt%~;1mOz6$-iI?!({*89q%)oyn2GKkjAjEfsuF2 z{YsrE>7C`Gt8}J=|NMRxy{voD{nq-Rp9dCi!i!-RCMO?+C4A#N)+5QyX9`bi|HQ4_ zT(5aFT_)^KDRtBLGXvhuTykbo zcp5CH4CgtQXodktM5qe#K34fkzT_uwxODdFxlpw4EQ9sA2|zf`nlnJ`gdi4eYy>z& zbP8|56TPLtTh#`hD5Dj;Y)m(eY{x<2o^T0;-I`q^hIZxF~+LN#e+JP!_9 zzF15EU|XOoY==k&#IvB}TSf4n`M4FVI&^}#%54l7D{r0PnMBX-OX}EdNo^i34L!FO zB%B?yE1poJv=PpFD7BQO1paNj5sZLlb4)rAc!_!77L3^Air)===iU(C!5L}#suNeM ztoTTM5R^BaMNz-JS^TnHE;DQTIDErZMLV;t`Ev(CK2`ELIM0t53cv{g6!(>^wKR~a zMr>?Rl#G=sc=c-{>~a?cd{#^fuqPO=Soca?Vb0!{TYOobO2lIOm*oYthr&6U?{{#` zVG_e~CJA?EyHupMC0oo?nZkrVQG=RTbo=v?=g)&Lo)QFsTi47{z-1z*lHl?XsCCOn zec|qO$3*Oo4OOSuYt!Yyu0qLW3N0l!LFR{$>I5xl$Lu^v;FX&gLRvtwDh6#&L_m>t z4Jw}-#W=BgC5;uBgJ1yVsZ8+3ckMnZd?_txArHkz z2(<2Y*xvj3o7E()b45Jv2Ia2tiY@qZ1!2`ML1}c9moHtT_=&{Oix{gPQNCSJp+CPi zsfu_jQ2J4EJksnO*W|d!-JMG^?dAEW*+XIs9BP0;*xd;%w@9@E;61D=pamk26-3ZW zU7M4__2$&JR7k4uzA9(=SZGWS+QP~<_+fs)o-(HVi^t`BWSRQ;7)PVVYEFZ-dU^;_ zzu@YjFig}B1&rY4!yj>{n?6Hu-A{b}czRS!bOgFXi*X;cCM#l!vw2l>%EQ;E*E1kMWF#xYkb`J&>M^3y^8=iwTf{pw$ zsMlQ8ZtV9OtnTJPgHyMuVH)07j@`u;VL@u`pax)Hj=>Y;#Ua`&ND~P8`%J9*x9lW7 zFn2Y47Rr>KzNg&%!|)(Y@SHDcRpy!f(r1(L1$k$JjP8{X&t>D@_!Mzkl&N5$c6uWF z&9VhW-$B(3@u{LAFb;F2h+_Pdmh?Rrj*0jETd?5rBR_0Issu&ErX|Bn2)2hcmgASa zrL<{YDizuCY(Bam)5CD-eI^>19CJXnaEaI-&B8uE<^4s4%clZjK0EYQ=YnnSC^$#f zwu#zkPcTpt$$)8mt;gbiu0n`~x$%vnVrHoP(<;T~O6SL54@!v$lfyE*x2tZ$4_gVk z_8bkQi)Jk+8ba!Q`>-wrYw)9BNuUeedhWcuqh0n6{8!r>+dQo_S=4AOSd=Nq{t^*EF!LMZ| z>UsO6s#(iUWfy{PB2KbT#&ZFTVV~?Ce-;NMUSfp`gj+ahw%s-LTFog|+r@?i|BU3j2!j_8aLjuOFo(g6;1PXWES$_VpCE=c0FMctv^mG!0s z{mSU07K&+YQRoYYnVTI+(qNPZT}oXZP&LHRUHFz8D&PEk)Rq0A9-T{Usacbhvo_YW zMq02xP#+C|ha-@QlTfbB(^d@SZ?k*z*GE@u)3t7|7$gaE^Dr)21w|kFkdUAua$W*% zRjGml=+srR*>x)f^2N1oRBBYU;|6)iHpIr*0q5wchZ3!dQM8h z;s^M`6HXUB%|27R8qlf8_W8LroBJ5JaJA#p&m_V?W@&?7$KZ6q6Z)c~zZCf14u5cx zJ?Xcs-KhkMTC#vDixFdi(5eE|i~#1;%X9c>UXjpM72@Q&bcTcY4;V&?U+)N;6dI); z?*y=b2sJ!ki<^-n3mjdYl$WY$FA8mVqu~N; zf7N?O`gKoOrw?hW6lH|LXi8I7o2KC>>;zQBiO_=@5IoqB3n=h6506$J3G-xqz&%%L zumwL^&*e-x1#qE{rrjVJ|HZ`HAnT&|ybw}GkM{hfj#EO^)cP}CG%zw9jfx>XjS(6I z&4TP4KlH;NACp7b2M{#;`!0T}Z7 ztcv~>?`W6LzPe5(X(WZ)UBOyJ!Zxb+l{v9Z%_9y1fedL?X;)Xyp-ayDzpXKs#iV^) zaPmH^Y&z{Raww@`y|s1JIc*#Kh~jb$4oBvtVy3ym<~XN#snK`a1*A>&Nvz<;YO@%= zBFEZI6G{SnAbgMTi;8q3`bn`sVyAOyK;h|izMAwWQm7XaqAh7VjNaL=wWJpv;28U- zQ!k^Cqa{hiW8f^?b&IhbKYNuYBmR)?HFh&Ng;;+ZyhDt`S;<%6f3MN7-0dL;-~E;cbrn7^#WiJ8FK@Ak|^ zyq&%H!{Ng|-PF>qfQ;eD$N?vx*^EQG34+z^=1-=ElOVuek&{mi@(MHFo1s@mqZWN^ zmtD_0&`6epfz437o}Z6(_sP-pq(@BMxr&vO=2*9^g3h$^Fyu=>1b^UWkNKy|HfE@3ovViCZHwK+iyVLcF9_33a5e++m`ihBpWb;v7r3un!fOwmNmMoJ1cbIZ(le1 zq!T~6`1t&afvW!+0Tq3DPalfWTubh+bVrTpH0fh2Ph{RT<0=P=zF9-c!`i#;|M6um zZjvp3!ndScu;o5{BF>jZ3BHVM*96o4X@lsFZel~CFx z4s8*4B!}G+)1TnrPjtzhB%xFUCUrcwOqXTXWF-QQTE27$veoA{#VJd-WG!=8ry)!U4yMRl(nyvqn_(GB#t^}Sd z(5FR9#Di(CD}O2Mx$`FP_`^N5@)D}GCAA|Kcu_5DQJtjr#W+>sN^GMv`pK&NOAd!I z)NYCdl(@K};4{C1%Y-ciB?%`Wf~4ndlClI2GAl{Fsuv|eCFUS#WvBPo=;aFCc5SWl zy%GmBv{Q8IRvT3r95TuDRk{IncE6beT;=s3H%`;|*(5H_=1_8~BfSeQgC);^?NqWkd3~ z3oGCbIJqo3rOKL%kV!D+ZqQMZ*@Qx!BnJzrmWlT<(NJ#kR(o~`LAfwu&*b%UF++lj zXiG%wJ%@Ma7`iyLDG9h0jEvWKvqtC9efe%qvVRV*Q0PVsZEeZ!0tTbw2 zb!`;27A3HGMuY5gdSsk?V+uiY4C<>1*P<0;TEqYlpr9t+pX7H|yn?t6IiP(7Jw1uP z!TgoKS)9NizR^wWmEFsuf^1AGk)#8%aaZJ!WQlxH=THs>DGvip)Y$pbr>dyUvq>ST zf%1WVB#Q|mvHaL}3#oT9<-S$<2zUjHrkVtKLQ;@5+ls%&$^ZT5tc&;+$uVr_Zh9wm zu~MtJ%Qsp{pSjxK#Dr25{4)8)|1(y}FG3#c(r08#GL0@nC<3lSO`YQtc;>mbbumYn z+jMo@N?)s+(b+Q*$OH+zHcszmi+i(Ozbwy|OzIycz`5R~_tm714s|o29fz!Z%7DV1 z*g>wpU0Y&1&22i5-&udqxd6ZRZSBJXlic{jzP`ok3-&C4&9OUy?t@1&bpiI7RFPid zl>oUQ4Mz-0s_iTSe--j~{bSf@vz3CZ@4yc=RN-&YZqEnf7)NOy&xG*Fn5KTxq7G2qo)PNY zl_r}T5Sulz9nDLQWRBFT(UqBWCV8hCX6}NlC;r{#>BfQ?>14guB5;rPj*) zS%Q`Fh-oxOC?RMp;`^4#YPpo7GIkN<*mhAn^9HiZO>OPaJ0k9%X zXYW}*bGyp@$~d0xR@b&N@KmglGG9|Kg!Dry3`!Mk|L<6!X2G3|L8`Qp_RS-YyZuK< z7STs=taSaaC0;b>aL!up zG)8Q=Eu2G<5n68#8D{9(-yQ*q0-a;p3w*pM2p47&gj$(JpDlWfUr3ilQGrsg8gG>R zfGdVpI6+X;O3_2d3gqup#x+%T-lBE9SkICA-Z+e&ifLB~V`-5O6e%hN^MG5Oej_fj z`*kz@Kh$M6XliYh6w+2DeQXpJsz;Wp$7w=#rHH}vhfrC+q<7CQB5#YrAob%O3g{inD=Qv?yKUoe2 ze7TzzW^3W*yYO3bgJb??AYr&oc)x|CRD&GxI;jPTIe1E%jH5t39YM}kMLn4KQpkK?bp95UEJ~a%5kP2Q{+ZM`G1C?lD&g!koXzUZhOuh?Z5l zu{(RoxHUQu7*Ilm8$l!{hX|0G#Yf4WZvBZknQsys#8Nb8^#S_;`Jxl~N!&)1tYO*o z?$${Ruj6dSXY7JFi8sP)9ogD+4K;dR2$4sGGS&z`an*5)$flYU$VAO)PL zkxKPAV-1Qvv<%$fmDJy4dSOi528t{?m|snW6-K(IIaYO?*Vho;=x?4WbG=|F$@zPX zLwHKhiz=3If`Tq4xzj?4C-*F5J+}5-N939wXOqX6gR3}TQ)d3cR~_fg6QsEjV9t~8 z&4EFngaYWl>Qh&8)}YARo<%b`G(%6f@Uetpmga=#jg*`BT^iy~HucON*=UJPN!dJ1 zGo98s;~rNb_xigMRxxG-s;#D{YMpv3sND2y72A%%Cv5p?eE7In%9+5{fDDCnYGZ=B zV%UOXPB|Y(W5>DWh9Eq7QKZ`D1)P%CBJC7sP1$1A7X;spDtsrc^(u` z7%4~6-V%?5EAxPB7)W5_B)=(_)iT!gHMW%U7UN@WCmMls#|}aw$$C*plLIo8<2?Tq zL3txe`J7nyX>PVF1ze}jE(R|a;k17rITfr!+TLkT2TwPDv!~9}?G>l$vcNP)ton_>CkPu>#Y8_F}ycBPvZ;mEKp4rl1!UsUK= zZIWsOM-l-BEQo#)2YIE1WMjrwj;Tc@U z!aChzc?O1x*&KuU-GOta~tuG!&QLT@hE0=doC9A&v>W*@2En2bN=be?krghM1^HfJ* z*$n41R-crBuZ`_BrPS{*)wC4bOjlC=GvH!oF#hV+Yr$KGVK_N-lX>J?1ArOyM6Lus?4|nLwYw8}g71k77=^K4LtbvKE+7w7 zBwlIAF4$p15H*BbaH)iO-kNh z9B>N$c3;Y*v~n=&6I$o=E0`DZE0Lb_Q)wpBYX~I4F-GAfZs#R}AoZzHp#_Q_l`wwjiCWh-|%18G0siINRdX#+kVE0Zt(A{6y+_M~hD z?ZksxOl*ZcrG8*_W4>3meLMExl+;4i@-C)Ml+l??0jhT@6P`ooi4o{h5^P$>Acte3 zC!YRxFh5u$LWoR*k|z}UTQ^j|NE)(mHPl*Sgr$W&Q$O}6Co~<@_;e3URgMTqg~d2x zq##GYy*%NcByR_XjQjY(O=Py4Tpg5!QPlw7_Hg>RqQM_#Yx&}ch=|+fUd~sY zGXSJnH9+Bq7sq1WsQcj!Vz+@p8Kco2l%W=xNje?HhOAy&w9wv#IZH|BZy>QF$3BE> zSAwz4WRI7|o=#pQ=X#CJD*4`@6ZG!tY#DBDB!&4Ebo9<{l60-xIc7&)P`$i#hfxb5 zwz;K)-_eHin&i9VzUumu8SwPieX~?L#%BEVmnO??)yy z_WniP8L>?rN`>zF#-Zd%gjPFS%`!(*{!_vew9ru+FYK5^9Ovz*E}qs8ERfXv`G<6g zdL$R$QcnCbtY;qi-Docl{2C7>?0K&H#7E38`@Rdm1E>5N9~t9?HZaGTfYax$aB}hbfc$_z;;mf>~;eam+$dk|{+&!Li^) zb7t_0&jyMT81y4+tG$catpT}Vx`S?AE#0JqwSHvs%v#>N6*%O?XxRpuhP2Af#oM-A zfN|v@PAZ?@7%aQwYEh9N%Z=!G>g973#z$G0f)Q8OxcE7z(>6aP({vJ)kRjVrU(m3Q zESE8lRooWu4J++BI$;mfN{6&})FI5P1i06zWrT`WXQmH#lj=tdnuPt4F+{ z#f=%pk#e!yFmY^8BbDL=_{vIT0zS&SL4h&F)HMi-KI-(%J@;KH(`ZVmSHCeM(vy)K zO%YNsQp&Ky|HwygMf?(n5F@*w#b)W)e}{Oi#5|a0O>gL+t)RTIvT122cJe(+2^&8v zoJ-u8s4Du>MgeE+mjfn+YjMi;qn>ooUn)ovkJr8ZqU2ep>D?jEJQ$33%s%Xkn$c=B zh}B+9JtUa}E++c5&B)|G?KDRDy#y~IT5`6JclbESM{zCVu;ww?36N3BAIvr#eGY`& z%9K*WKQEno;?FV&gwQyyJ?MSjeilDL&uDVo(1NHf8Snu!4lR60Vm-eMy$>MnLDF1` zw#j(c|5JY*@UwIFrTeMJJ761<%GIXrSY|(&ox+VMWAusu(V;M+krDYgG($a_4pW`D zb`9ZaXo+fVBvTuqZfqW#+E-wX`^!d5;?by}KHt^-)ag1IW>sGtmvtz4K_+~N3Amab za^#3B@1b#Ij9pOW^i0pJpb}jsUqxln9@TGm1PKjRIK=h)kEn-@W z{FPQNAF;jwlGSbJMfo)QlTt;D<~MJZMMfFyk1$Y8R(gyVF8@UA5O^iwz$7L@3S>=> zn5Bb8EFIIsCOqR&hZP)qa1m`~k>HjJWBw6`#yav#p;g$gp}!ZpDKW2%?}P_H;$rWn z+*UgL;#IFIZ-+)_nvxJ}>=uZ7GEihsSqg~I>60?yF!ho9MRoj`(Xj?TVX~u+)Bc?viz4bt8xZ@7{2IW;#mc0q8l^WTT zg6H}G(Wuk&j*~M-EN`YAn1F~aHc&QEz9w9HyK&&PmKnNeWDM%2`C}bd^sB6bZ}}Hz z#AVC9W3)FZ)3g%;WQEQivbotWXr$4N{zFKS9VwRULx=3FDsD=|@*yshBd3M43x7nM ztrfF6VvLlm^6*}9EamG?4n>O1!{yzn1G&0BvA0-W{n_Drt;{95@&^>020?^Vx7bln z_WRk+8^kJ6r>UO-T#klZ(tZ^JufjLES0OJYRidTB2D`(BiFU<0+8io3F*0^61Uwj5 zPinYipQ5ys6gJ5^Y>0cmSo&kOShe#|+=FOv0v_r%V?Ik{LTZHTvs3D_d`L`*4vaRsj%^p(f>$xm|lCbk;zd z;tk()DT;N>NwIcSP$;jM=mUqzbaNtE_dN%&2!ClQJap4Fev)ZZV3mPH{;d}H!taJ{ zs5PEmzGYjm!OHde@DDWadYjQvXAM%$^s2lY4cyn#u$zsC%xiyKzZHj1b7)g$2siBa znj0wJo<=P6A7rQ-qIXF?H&-^TD9f^HrkL-&hsjzhF@I_MhjtJu#c|K)DXT%(J4UHKEj&S#S@^$EO)lbD?5Q^(i(euC>3ljav>bXuCU96YAz^x~!;J zMaeZK9PtS~e?~#m5%N@>RsWzrPIm_2DM78(k%{Wm73J1DAtl-bdE}Cu=mIk^BURm`qu?c7-$fx4S_gDLn zR||CD@qFL?q56OKpRELU=fDto+LIV~CnAJp^>+v|Mwu4+Z%j;ZHs{0!~fcpjB^7r$xzbePnZUH{FIX|cPYq63R3`Nb_j z@ViRawYSL$QG<{!1)w2BmNg2U3A_#q%M2q$mSQKI`#B$QyiP_bFB*A0Zsip|I?K`* zyrT_}64+C#agL>_EO$fERR2_88G9KW>!?T~XEcg_Jrqo9*%YNW##`bW?>$LHOh)Yb z3$?zquBJfRPUE=9BDa@*Qxhowp`#j#+qDIg^^G;_)ZTfNw7#%Sf7jxj8BHh~{W%(s zj(f&a5B*3PNvR7Vk$@Hm0nWJ>aV=UvRh+Pq2=&H(!3XD{jI)@ALk}c6S>=d1ekH2j z1asB5cp!%IWHp1VmNLg&6I*rR=(T4()0<;yDn0;4ctpruHRXJO$#p=-{`mz0^l1=P zPM{}b0m;EgdhX)CkIDw7tw>AM`QaRs?3!ejN>qoUq$6eBI!!{<@Y(p1FIK@xLfz(^ z^W6`7D#2qc`3<_zF@bL#MiBb!5BTW0cz@rXVOt9++~oOjLn2=i=Y8YI@y_MdmeC zm%3x(rTENis2$L*0!I+8d0L+>*UwGPBJCraXQAmDd~*vQMAa!estFKDedYYUpMLsH zo$%8dkT}pCk}t^KMMDy1)hL;rl@g`4fs8b_!{5zwFIb1Ij7j}Ws@BNF24V7!CerH| zkfkl5VA*+v@usDKFTRD)pqmHrL~$0{<~TFI*#%*m93-D-_l8vv>fn)ZYT2sTOjgCe z)qOaN>oWaIf2<{8gF0Wo2R-p_(Io2V?35>%?bl8(Ud> zDs#(8GK0_-+vY+F6Zu)}ofp+0{XV#Ti1o@LMj}e=(=%s6>-~tb3hFvPOXFGVcgsDl zaI|ATp0!6)JnFeg+u)Nxe0Xoa{z-f6YtfhOJdHC_>#yZAyd-A!ZY z8qLMKvqU|Ak?m+xuB-Ig{)A(d=MYJ7g{oWf9I%F3F!|T+LZR$(cy{cx44zrd0w0~V zoOPenMZu}d53-?<~tbnr=Cl;5a_`zY5ap1}Fdz#kR}YoD2rWVMLQ z={_EUVs#PPB%J3Z(3S=ig~FJu?zd8g>Ovq>Tu#|H0#(SVWSHNrrhb(%QCxPxZ|Nxg z?KqqdNpA0TR6^N|{kK5Lr$~cSmA8!J_MM%e6UdAw;{p_0(>SX=lK2Z;-P7#)${7IR zi_8H<%2v!Sx#P#jX3lR%-VELX(fUSmKI7j7&53^Ka$FOK3z*Tv4yEH5DI$nEP{m_@ z&4xBv+7OKicX{w3XEx_E7RS?BOJ3v~o4E-JvP)@gNZTCkCyAwgsxlz>jeT@6-8kuf z{#IQ0afF)m$_p(d-!WKt9VjZ#k0NTNTqADg98%5Nk+qGdHz>TxAoOk}jj%C0!~& z@ni-<91;Ovso3Ro3l!TUI}lL;8yE`6n+jmq6k9wd!mVuNP`j}Px3B`1g!17j7Hs)v zS?pKP354w^nsqO9;2*rLWI;<+c%g;eDhG{_iikb2l5Uvi`JoHcZ0Z}c;Tw^&g)qtr z+BooGH4?PN=BXu>Denw^RC&xjXvrDd!Qw4}I=Y#DGqwqio?5_dk7_7#t`2Df07fBP z0CYPo%Ej`c>!(uQf^h`ZuAWK4M+~`$CP--zhP=^_#6@n&(J-Fv{-thWMmhZ7J+Byp zB|*e3vKykAGqQJD^^60Phhd*7C}*xtd7*GXEscwKjCUqhh`-Y66H5yE$@7j+58mhNB z=8~L^mj*2|2HRSxZ4pd=kx@UL$Q?T3ZD*TC%n5#cI<jgH{}gfZSrK)f6*`R zSgSN@znRAwns|N*c;&)Y;^pX;iJZkeMRtxLp(RB8<;lzOM0#=WN#`V&9OS!?O`g|- zpL=;|ePCsd5C?nD%X+O?4~xhp7fm*7gY05v23VULTQQXI;%cXTNq@6WKq_MG!pM6w z;#N9wc`bEK+4qIO*(Wl-o?XxE$E5mQAlnv%L^IvST4L)W>S9qD-|D0QSBTNHG1yR* z!c^kWXc$f_y4GTdrkLOx+BBZTIWK6u_~UO_Nw*9^zPD?_+kV=T4oGFB_+)SrR*cLd zkt*1U$bxy5nF2-Nb#a#m5xMIJz}o>85rBkczE>D8wa;%AP_2MLQ)e+#^r#KN5^sH7 zZWhu3&455&SAbYxvUXj>Jc(&i^x37R<1P<52}tQ0?heA$mE$=QkE&9k>qh$0BQ1?&xN$^EkA(hynL!hcL242v1Q@X=Ju8Pc3!{= z8=kdc-PDY|rRZWyv1)D~G_n3Uy9-#FtFE>$=$Rtu`BfbVdaZrNKuwZ&XcGpyzWWW_axs!GN6R?MTm=Ne4Ou9{cd zHZAP5I}q!OX8wB^5@Dj4j9jrp8OQeYAs~!_X00;_nV)-a%-@b*eV#Cj}UBmvyYfdAzhlaIWi zxB6$3WCQm4|4d3Bt`}!N^Eiop+N{inD%r?q1;2Po6SL1b97*YAA8myz$FCJrKfiax zITV1=KEW(?EvUbAvb)DZV2`zPLlYC4hq-FTUFqOF92|P1WbkBwov%=B~^r9>%);He4M#B+EY@%&=T9) zu&mh?RlsOvn=fU`!S5-LMqtNuz%y#wvs5wcAKuRd%{fsOmd@?UjuCbH8X0*`ad~X( zaBOV6Mny&OLqE~&W$&fi@RdvM@g5PdvZSGV-F~ofpq>bU>CTNrc?zZU`jcVQ9z|I$ zJipD%btiUTroV#91>rHwo5lqmv91zm9_&F8a6wy2)6CoiG%MBgxYMxw)WRkvoq|}Z z#ungO}go&sq?~L zmko_D^845~Ld2QML3E1{UZJ5Sx(m8jWLva)D|ev|6CGO`h=7Tl^`g2u`LRogO;66N z#N*FPdjsZ86Lm*ZasMky<1Xn9hbY2?wqKR=z>0UcquJyB`QqLiLp5u9Ics2LlhI66 z$kCSW{!alxwW<8zQJd}kW?(SRtLJIywS}@N8hWLnkJ7I;chcsJmELTg_Fxqi@%k+> z@r36frSe7^IAGh@mtna`gWuRl`a?&)ybB~soGhiH?ANb5Jmg1&n;-w*OSi_lyFPn&m|rNQ7B;zqB6*d? zXs~}LIfy%_hUp&+$83j&C@U!{qKi^tsENdU>spyll<+qi+f4ShntqoE7}ERAiOjXy zIJ?zT^^|Hyhe%zW}x^G0xv(mcflL?p}kJcSVgfMAM_Da|O8 zr^gu8d%q8Rq`X1u6rnTA#5U&yT)0lK(=d@{_5kZhoxx4MJ*7Wzq7!7OnZ>brYjznHP4cSygxnEyr zjGsoQij!^5k^F|z(daPVXT?XSXEIEL*$^66IE;pjOCq`Sw!L=MhL-=-Z6_Pa)czfA zIH_W)yjT_&$<(};4q^^ZdnZPZ_kEi^Vg}jk7b5c=wAUwUjEz2Xz6L#Fui55&8m3x< z`&CTUf$65Ln+tL-lqzut0hrvnNJ8E?$&o@nE_BiqgHVxAI6sfSLMIn+5mD4u;~HDG zDcgbwed;T;Wr42#erfUvN5Zq*n7RF*xzWE7BjaYhNmzxK19LKlV|+7-7zL{f)2k&Q z0i!|0N^FlLS}m3+k!j3^nG(|Q&E#LOK+P{(IKS#i4Y@D27##Ui9^hnRAV0c&CoVpk zt`%k!_KoBYUaiuI6Qcom1|voX;F!sQ2nI9@rB18~_5?OCFy*UBwo0oGlitlgjGr8rzjSmSp_S5seEm8i7%K`THl;|jYe1^#K`x%qhpJ9 z4!q6dnz-!zBn#*}*21X6HZriW*f<)lHB!^_EVgZ!Z!243L`~roE3CGhBME%V8&XoXMMEypdAYU`4LL5;`$v$Dt;Abj)3!GQd(V`dcu|;p=dUzRHeJje>Vx*qC&df?Q{wzbT_opcZM8`8F>R zVY_eZ|GlWg8b+0{Kc=9I&iU~q;h^dt%1vp)N9pi+##CJLlU$&O71tAn+$fW9SnvC&|X{%w{6yXQn*Lrt;0KDw68#Z`jDdKxIG>+pu4js4t8_?M(+Zh-HA2n%^ie(S^Y+yo*KKi!W%#Z+!O?fNJoH?;h zwZO;w{^y^}_-)61=jXXAU=u_PSx2n~Dg`qZZ-ko8ZFx|z>l*2NaN6~-(sN-w(X|-! zSaVPuz#VL{`jzY(hl2$$rB+5ZhC1tI7Qe+Tqh-J>_<#T5O6XnYSP5*3oq>z6@0y%H z)WRO+Dg1W|bHsVkli70}zeg>{=|plYxH%uIT4&7tLBKDdom%ceqlbV5ae5)YNN8th zTR(?!kbRw)nGa{5{79I!y;?&gN+;(30ji+!rP4bJAh=@IN6yUoWXIO2p>3}_B#giW zrH^1wLH< zpnBFG{>2rM0BLIUtph>2wWcnYQ6pyj&@K$~(hJ2N)>78#Y|>f+A$pE)HF~$QL4FgQ zrg3y-D7hF#MoGCr<%fCgt_7lf{AD}&Z@!A!=CUE0GowZSKF1}_P=Y#~#=IIQ=#^m* zmEpDhs-{5}4uHP`^kqB>9_p*&banUdcvGh`BT@s^KS(B7oI@3L4Z26UTTc>JOZfo& z<m9bE{C;@^2P&(@j)7@f# z(XA0jh=n#Ma00EPIY26d;4a}L1+c-{<3fA$(O}!OK99hsa z?2I$GPI-OLp-7y&@{a)Lz^I69phshRQ+(ndck0&+7LadwEKK}Qc9M%9zEt;0u*Ax4!rh6~ zZdY`A-VVbzxK#OHwbvsHBU%1^+7x^IM{?%|zmx4CPqU#61Crs6Vps_heRZR5&#;rIho<7|{XoFV?_GOe`L__%`x zmZun1X7i!?b(gUQE;auC@?-*N_I?yX_7)82ToO2tOsLa$fZPFC_5bMGu}v;#qVM-x7pLVQCWC6};=C#dU6ZYe0IhJ5#^8dKDg5M4okN_qB{=&Y9 zRYwIn(Z8vZq(2!)zHVj`xxnmSrP1Ma{j;l z%oSawtWPt6>1K5Sz+$z+NgasxAt{7S V;y?feIRi}W{{R3000000004Hxb_xIh literal 0 HcmV?d00001 diff --git a/web/tutorial/film-project-1.webp b/web/tutorial/film-project-1.webp new file mode 100644 index 0000000000000000000000000000000000000000..b1f7101a7b210e51baf073c62dc48e581f16364e GIT binary patch literal 73482 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 V7A>cAat`Y)7bLdMs`^hX{{@Vg1~32s literal 0 HcmV?d00001 diff --git a/web/tutorial/film-project-2.webp b/web/tutorial/film-project-2.webp new file mode 100644 index 0000000000000000000000000000000000000000..82e8980c97f7dead02502aa85205e33f883eb2b1 GIT binary patch literal 75150 zcma&N1CV7+(=NK(#i5x zD>G}ylPjunDN2Zn{vrbbsEG*4tIBhz!vX*R!oL*=4!{EgNC*qdkAnPt1b~eG(}FVs z05-PHj*4PJgz6fagit#GkiXY|ih+@n{lDn{*#4DzTK-pdf$sk(@qh1xF*b2B`YUqt zw~{*ko&2v(Xn!%9*}s_bA8hz9=J^M^Iomt`m687kJ1Q#*|Ha0CF{Ro6fDQi#Y-I2F zk9^c$86F#Jmw)E^r~G3byos%f(%(Du---ut0w@B+07C!R|M&TCu`2)oI3582u(L2(l;FYjEq>fdQkyiQ$CL~Dg+#8`H$L_rK;O^utM1;- zk}u4dfsLvS| z-9q5Vv)^6Gmt8f%vhDyujovfAg)f5N?GyXwGmyW+?;LpP_XMnd_5_Z+r(d7H>wUzI zA;tTh_v`@QTm^jZ-r;@M{jQ(9fJ^U1H;!V&=2w#7FAO=GE?5@4(N6f5P|P2Miec zS#xz=%%1_|1^QiGA8_CGoD!V(bbMX-CBMvk+I`@2zL$K=d~Ba}g?(;*Abr@~>2&~) z`2anbUwUsn8@b!SlD{epe{=#tfWRAySKt%UW9}}$i{CI12%Nf21}07$&H0@G+dtCZ zp}w*|w?D66w(tF(diwv4U%sq;#eVTW>RtLh`?bHoJm!88Jm#JPE&2L;GJvMQ)GvY$ zAkgk1_pj{d^E>C~;$PsloBFf>+$CntT8&vLr1X)&LM`ahjVh&E%Kd)NS~TF?h0U;D z#znJqG9I|8al6eTsm(4mm+)Dh_`ir@HRP+~PKF`t5b0@U|5hC1idbkuhg2|~jO@lu zNzZ9@XJ7FnZ#H=hL5n{gCOFcV=Iya-c7I;i1OwfkiFxJ;&Dv7x22}bps%b4)zZm-) z+)~+ZY^RQDH!evzW<;)lZ@D+ahq<6J)F^Ird8X_e7PT_o(StUiq?|cZF#NFc@tifA<5r&+Y&_5Z7pa2=*Mq~G1x+L`|zk0 zJLA2($;07+Rx%V&M11388<&NX|LXFW@4ZzY3gOpw6MOJU;PYd*Y~rE1~dDF#A|$0f*7h{L-u)$g8Mq7y0K za*k!`kC9!Yi+h)`Ud#C-R(vI9^)-#@d(!><&fDqi8vh$9j`vwdeZ}cU8BrQo!bB@_ z^@)w^m=19fZ@sD%xa<_o;*#-ervOJBYk9o&gAoSNAz&P5l`R6!e>`Ca{-9=HFHsEf zc%Yx;`>*X}MlsC<9&n|28Td|#M2q!!T3pGl5Q`62cab6d@1pJuxB+!B^d7N>L&U(aE*RqrG+@%=Tal-jw6}iIgf1pUFh=tHTFpRX`&dl`8 zOF&jm;UU0mY*!L|-;@Pu>}GrRwYFx!^gF6IuB1m*X@CTop)i4@1u+KDiKVY$zkdbG zg@Tb<;C0K>28B?@ou{gelS&8pF!k!u2rZ3ol7I8-MjJrcCE$dmtk$Rc&bh_W7SagMz+aTOyOy$lrdAq#9X!}j_7eG!8gxe`At=dklKfaOw?diZzNrj>h5**DNEJ% zS+lU1{Vl#+Je(Euhriql7+)_Gk${Y@;%4p*+d!v3zfTYRG@C^OLL&H4wIhqlq`qzY zf_c3nvCZx`&G0$c;y^2`+`R{!W4YJLaB8g?YS^s-VX}iX<)c?IbZgS6bxEnXK1LRCn^9D^q8ni~b+G zXhAIZnR1&5_A+FjNAwTEH#a=ByJ(P~G2wQ9>RG?srhf;%iW$&!XA*68?Q&%7&eZae zi5Vc6F|>)GIhBdrw5wXAZ_O9Dh<^ZbZHZf2>ZT z+Cy*rqUXr(Tp=*~AQt0iXg8-Aq#WXCK8JLwR$$+GKGAJxBGmCLZGYsY=V3NB@m-Rq z>4}E7VW{vJ_Q?crxKxxpYuj7G&N^`Ma)g+qOP3}5;iN_-Seo6Ek_OK~J8GlrVA5%$ zisaG~6r7Dg@wpF7C*R=U_}kR=N<_8w1-=l$z^4g_$ooZ*R2ksYmXamePPf; zN6%u}V{M6{bWC_5YCz#P#oveD>fSuK(3L3}CTAo!L|SS>_0g$Op85hl%xPCtN7qIf=MHHNEgMGrHu6{ zR8tXS01RqCpC6?_US?{(_{0r8xm{|gs{MX8>Kg+1ONv4=m!sYfpmAEb*g*M_#5`ms zOJ+Ua73QgsvJ}0ahp0|E&o<@?$lxX!EpLai=kkM1f;6_wes%Ci>Sd(vXG+Mp{PCtQ z$em$*iTaMf?v9UpPPnv)Y*^E@V(yG4Rbk1N`L=JUvlXg+C|E@$m9YhIEvbNn0o%m- zaJ9wLvS`R@7;$xJD+zMRcT6PW!exVIce6mUF5}#TslbHd_4KqgmB%IO2}e_zNG&89 zM=mF1^!wLNbE~b~)=eC3=_luiQH3;e?M&m3m7SbHMVT!nhy-?XBqS?PdytTsp!or! z`o$Nb#%@Z;at~vNqRA>iUi7aH8LBU)xm|+LEOWW!Y+mL;M9$>+9p_8el3K8H%cx_G$mdL%5bMQ<^yX+b3IeOb*yhao@CS^;?O8zAdl)amC zc=(Wae9M8ccTU+uU;;cy_bVZCQ=-2M0e&3)VH*qZWgD44+ zMknu~KZd0}qB<2 z3t{XERtklg`-U?@@7y0;*|i$RRHPi&FM;V^Q6sGEixBSijD*kBcggg(Yq9`8N2H+! zh^Qg=N68t>;RRUBP%O^n>>HxwLX2*ofoEGQ72U`ey<3tlTpgv^B(*jery+tA172?? z=)KjSdD9IIB2OaYfer4$PMwvW42;v0-&n071TE<9;hZcMq{jRKtJHKOR<(JcdGsAi zu(o38(q_Qh&_C#R1*Y4k`zMqvM~+lSb&%;(B<{K5c$5zglI^K6|r7pI~y2` zA0-bG8+UB9*C*8D!K=!f->wM5?9Qo;{!UsRdFt>6i ze0M6_s)|0A>{w2m2z!MqSY8ekYO9|9bW&l6s*!qaqx#*k2i6WMZ@;j6;EeA8V!csl z;kVWDj_?^I{LzL<>tFUz85Pn?x}@#={N3iygZ5(FCs6v0yv-hIOv6TApey&jyW;Xh z%~KCU)kIS7zJDDbK?Yw!WMN}&h3rsDB<5akdbO~ zQ0;v7ZB7B%Wgx!}f4-8oGEOYeQzq?6c+^=Vbm3*F-m6Oakwh&?2cblrei=2I44PR` z`uJ|`?kQ3FjS|JndFz9iGVkUF*-;a+ZPYE%ge+*Mr$gW6oc3j5xajN|n1)HS%1mBY zD_lBeTFLkcD_bXOYTp)HdoyyY1(r}^gqnLY=0eEOziJYbcd9Dr6^`g1-qL`( z&CDSt`t5?#nqyyQomNN1i0B7vfbwqpyow>nB_hd#)mWP#X|AbdhI{_HGlyCwcyhZF znj~JIV3S2Lr2NEeA?^>di3Rg}0!8dM0>@3AYK&ttU)C9Q-y-15neBp{zG7HhBbahH zKdRq>mV;O(r12M`JhRa7MQ8l)9ZECP*n7%9&kv|$~r>OK@qHXNH3431OP?gSosU7C?GmUxnUk(8@?m| zYzn{QO8qlX(iHlQj`uph`7Po}#pgtMUQz=x(I2ur`?6C9otrhxuN<8YpJI3B9+CaX ztQwW%{#5hlB^KL}nU#SFUx}I&lJFZv-0jt;)1hSQQ{;_ry873h$m>%q%xa$&PVZLU z6O4_ff8vUbFyB-V{{lJQ7aj4aXXkj#WQ$p?4?^nz3^z_p%Uc%yrN7by9ehy`A&rH@ zb&rEhi&`Or){isJgP$+lUO^k-x8|Y5IDRA%U`&Ve$C}@%&~l=J z%2sm6^t6nv=q;F`Wr+iw7>03bN+R@IKRS9#Id+ltA3;xjn(uJwyu58&`#RRDQ`{d< z%KT_6dyMQph(zW|+QB&waZv#S_;`$rIHyd^x`dY|#nmWblzYZ*Hp6XP_=o zyLgo4sW=C{2q;oQ-SX4hB*E-YRe!g_9;d_#;-6Q8gs=iT)TvAr84t~YfK&kYOU$3E zy|W)7q}n;?C6BQwyzTcmbWfry8EFXcZ)}c*mkg@r>>fnEX8n-X}+fo;id=!ifVmoNciB2@Nh*KE* zQ&xR740ZLOLLtRkz~zjMA}w-&A1|KV4BN<(SR;1k9R%UEK=bug~rE6 ze*o$zVB+0I%LYdZf_zLzHZc%UMBC}oJiRLBN%TIzJe?pV+HSv90ZAvI^OM3|YiYJJ zCqnB(nbb5{%Ui;>G=dWa_KnV7o$ca)vguTGp);^@l6Nl7NatcU>S;R6uv8z*B=~+9rACB~)AaFd0 zJHHrN4Q8_$2uUPkNh^5@b zAvkn4;;qt!7}}BEx%IDBr(<;rO2@+6kl3WC1TFh}iW+Wyc%^fkLs!>8=RYT!jS5~} zJUnKvezJ07uJZB06G=bkE3fcUrPzLa2zjg)=Z<094Zn(}nE_CuEl{Ba28 zksOZx%)?H0zxg6WTTE+g{-UL7k>!4mug3J>OeO?s&PQj;*rR$id{s?hA393=8{nUg zYQ=VLQn<$7s2o}9efyCTwJgp|Pp{E=O}#WrqbqH<5pP;JW5=g#CBbR^HsD*tNaz~e zqdWHGC(UFc&ACAax**k;fo)4!^0O%x5>Yj&EgKv4)N+I4WJY*9()LsT>3O2D6^v`e zGSuBJ?SYI>RXkB3c3J_ydpLJMmcIS`El}BmQtD7UIbAWcaxZ#zEv7NPjjEJL;(LtW zp+W*`kRVMwr#QbBoOB?v6}G@E_(He!zQJ^oE_te@mc~z>R?Y~9^`ketLytFk0VV8! z2IP>TOYw0(bOt3#*)d;npD2>M3&5zOe?&uuFJgQj>al-kCm}laPJ;P}zHf_U|L|wA zjm^M~ncnHW^l)3#9htyyg9Bd0MsW~2s@>Xq(O@TLc)F7L#`Ys#!jZ(#k_;(V*VtpX zX(AmqDzV}7(7O+q5_eX0C}I7j_d~)Q>Bxx-n*!uL9U!=1Uu3j1=#{#@DQB+xTB0PQ zg~?kDyiBjDc$2WSVXuxwJNs#~<(?Q6yStXHg$5IGr6ju1I|BN?4ySZF69Fto>y|1$6H9 z;hPJt3WA$Vhw8laNzFpWCBNu0J%ESdU*d~AJg8Ug`L9gq>WXtQA;Ou9c<=-vV1?8& z$LkwBntk6<5M6_Ru_c*)*uKs_zcqkrg>rj!Er+KD%hDBLvw9!Q^)>S4lj>YbZJlBl z{90e1=~e01Ex9S`Mw>^6)W-}r7JhGY$ZE+x-RDbz!$8A|fQS?JVQ;$7d@|WaQtwqL zbC%z2#ys#i(SghoO3k8Dj8oM^&RlS65gE2r2YV7VlQTj2(S=2AAlc31*B*+b>?!2f z?7g;3_eBnJp|JME(&y{K2LseG-O=3o%61}T3jbc$oBnI3Jqk5=(@iVVv=9=Z~LN6&Y%LH4N+Sq_VoXH4L2 zbJ+W-mdJiChrx->3kE2aW<~OfNiSO-^*zGw$ho6(vD(1Uhc1i4X(r(+n1OGiRuU7A zzp1z?+4fyAdliad%P@+Jw4^|6G!p&F&Y&Qkq<|QF5l3+B)gMr*Nj4VkH)2z_S;tM% zy&4U781eQf!zdUn-GIQk)|(6}UjLBm{M^vVXQW;gq@Q3zp&?LFX8kExv!2H~hVjL> z4Jn>$uakL3xm{JFx=3^O*&k~sMO32peL`=zq839AW#hD8x=Vx~qhPbZgF6rBPCerS$ekw;fh&h;|5O3>QA~(O);X9A zhpy)+zSt@8JFBDcw=I>!Y!PPp3)^q^uWbG?c;Km{zzN}@xAI3~&5=-0tz@iRjVVUr!WQ`)<0%$iZ@j(ok8U+xVd!@!FjD7&*)I?RG;e-}FRXh2b=$(fLo~f-dr9-YW#0^&)5>4}@fy^S&=xH!lI}u=^$lI=2qI35eSuykyS8bn%ytqy01V zfIh$eJlP5Ma$7+Y)$W$tSnfW-)vwP0<2p^nXTh=T8@E$T!9bX|&4MnOdN6H3T@m^e zwzl$-0GJFg^5?i?RlFE`Mr~d)=ra%DnX-RLKj|0DMY60zXp6U$uJO_^ZGB9=dtXjc zv_kNhoxr~1Y1k_1hL4fy|+AY`;1HTYD}Sg|tQO1Of>A_h8UZVD)@%wY8&{x?=E_fB87 z=~YEv=J@=>W^NSUqawl(y9g|akpFI&NzItdOB=S0qY^miBne%eyjUYsw>t{HNqkuP z@osWa=ZndV_dj=m;CaOp52QqwYd6Y}fb4b8U?EFzM zAHw@Ea^9JEl}9TG*wy#%&GO#J*U&PPCdmEnMXskR5kII#(`UF8nPU5bABIWIgEty& z)#;rw&KacGWv~p&Wp{R6uY4QSzf5cv;l?t}20+*e15MjgD~dN!i7RH&|E*^a)55u( z^;TfM&3Yg6q?0j}7(yIpd-Sb}YMiXH8|$ZYS1sESqOv&El7+@$RcU9udyRbVlE;4a z`97G1dv*%!e2iKLkIQwpq^-T^pTH>w_-_R+i5Vbv>*LRu^mJsqLqa)sM`7xpb*w$g z9uL9>OAxNEGkjDRAta{zdV)t2MTdhSj6WKq_hogeU_t%6Xnv52w4pty-;r@SjyC#n zx-RKyyF_XqN(rsR)uQg8F6;; zBp@57EE8`W&crA5ZT2M+0#xFy@&vSL`f3=G`KrGSfym2_w!FG5`29p`d2iP(vmMQi za≠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 z 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 sWFKe33yP3mKCUQ1C(KF?QR)dPmscG0vE< Date: Fri, 13 Nov 2026 05:50:44 +0000 Subject: [PATCH 38/43] Chat: a finished short film comes as a finished-video card with Open and Edit, and the Edit on... Chat: a finished short film comes as a finished-video card with Open and Edit, and the Edit on result cards is always visible The chat read lists each film that joined successfully with its play, thumbnail, size, length and work; the chat shows it as the card a finished video uses, and the newest result, a video or a film, leads. Edit opens the edit the film's shots were cut into. The hover-only style of a message's Edit no longer hides the Edit on finished-video cards. Built-in directors are given a short while to finish when the film service closes. --- freevideo_engine/agent/productions.py | 17 +++++++++++++++-- freevideo_engine/agent/routes.py | 15 ++++++++++++++- web/autopilot_chat.css | 8 ++++---- web/autopilot_chat.js | 9 ++++++--- web/chat_data.js | 4 ++-- 5 files changed, 41 insertions(+), 12 deletions(-) diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index c696df26..b68096db 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -743,6 +743,12 @@ def get(self, identity, *, since=None, wait=0, compact=True): self.changed.wait(remaining) return self._reply(identity, since, compact) + def size(self, identity): + """The width and height of a film: its first shot's canvas, or None for each when that cannot be told.""" + with self.changed: + shape = self._shape(self._record(identity)['state']) + return shape.get('width'), shape.get('height') + def _reply(self, identity, since, compact): record = self._record(identity) changed = record['state']['revision'] != since @@ -883,9 +889,16 @@ def tick(self): self._run(identity) self._direct(identity) - def close(self): - """Stop the built-in directors; a question already queued is cancelled by its own wait.""" + def close(self, wait=2.0): + """Stop the built-in directors; a question already queued is cancelled by its own wait. + + Each director is given a short while to finish the step it is on, so nothing writes after the close. + """ self._closed.set() + deadline = time.monotonic() + wait + for thread, _ in list(self._directors.values()): + if thread is not None and thread is not threading.current_thread(): + thread.join(max(0.0, deadline - time.monotonic())) def _directing(self, identity): """Whether the built-in director should be choosing for this film now.""" diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 57f58886..bc2c8a3b 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -1049,7 +1049,7 @@ def read(): store = work_sessions() sid = chat_session(store, wanted) rows = jobs.rows() - value = dict(agent=agent_state(), session=sid, here=chat_session(store), last_seq=0, read_seq=0, items=[], jobs={}, failures={}, confirms={}, current=None, + value = dict(agent=agent_state(), session=sid, here=chat_session(store), last_seq=0, read_seq=0, items=[], jobs={}, failures={}, finished={}, confirms={}, current=None, active=sum(1 for r in rows if r['status'] in ACTIVE)) if sid is None: return value @@ -1096,6 +1096,19 @@ def read(): or (task_view.get('result') or {}).get('report_id')) value['failures'][identity] = dict(id=identity, kind=kind, status='failed', label=entity.get('title') or entity.get('name'), error=errors.with_reason(error), report_id=report_id) + elif kind == 'production': + # A finished film is shown like any finished video: play it, open it, or edit it (its shots already cut). + film = next((o for o in reversed(entity.get('outputs') or []) if o.get('library_id')), None) + if film: + from urllib.parse import quote + work, seconds = film['library_id'], film.get('seconds') + # The film's line reads like any finished video's: its size, then its length. + size = jobs.productions.size(identity) + value['finished'][identity] = dict(id=identity, kind=kind, status='succeeded', label=entity.get('title') or None, + work=work, play='/freevideo/agent/chat/video?id=' + quote(work, safe=''), + thumbnail='/freevideo/library/thumbnail?id=' + quote(work, safe=''), + seconds=round(seconds) if isinstance(seconds, (int, float)) and seconds > 0 else None, + width=size[0], height=size[1]) value['abandoned'] = store.abandoned(sid) value['stage'] = store.stage(sid) value['current'] = current['id'] if current else None diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 6d01f6d3..7f5c791e 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -217,10 +217,10 @@ /* The newest sentence can be rewritten in place. Its Edit row keeps its height, so showing it moves nothing. */ .fv-chat-edit-row{display:flex;justify-content:flex-end;min-height:28px;margin-top:4px} .fv-chat-edit-row[hidden]{display:none} -.fv-studio .fv-chat-log .fv-chat-edit{min-height:0;padding:4px 8px;border:0;border-radius:7px;background:none;color:var(--fv-muted);font-size:12.5px;font-weight:var(--fv-regular);opacity:0;transition:opacity var(--fv-quick)} -.fv-studio .fv-chat-log .fv-chat-edit:hover:not(:disabled){background:var(--fv-raised);color:var(--fv-ink)} -.fv-chat-me:is(:hover,:focus-within) .fv-chat-edit,.fv-studio .fv-chat-log .fv-chat-edit:focus-visible{opacity:1} -@media(hover:none),(max-width:480px){.fv-studio .fv-chat-log .fv-chat-edit{opacity:1}} +.fv-studio .fv-chat-log .fv-chat-edit-row .fv-chat-edit{min-height:0;padding:4px 8px;border:0;border-radius:7px;background:none;color:var(--fv-muted);font-size:12.5px;font-weight:var(--fv-regular);opacity:0;transition:opacity var(--fv-quick)} +.fv-studio .fv-chat-log .fv-chat-edit-row .fv-chat-edit:hover:not(:disabled){background:var(--fv-raised);color:var(--fv-ink)} +.fv-chat-me:is(:hover,:focus-within) .fv-chat-edit-row .fv-chat-edit,.fv-studio .fv-chat-log .fv-chat-edit-row .fv-chat-edit:focus-visible{opacity:1} +@media(hover:none),(max-width:480px){.fv-studio .fv-chat-log .fv-chat-edit-row .fv-chat-edit{opacity:1}} .fv-chat-me[data-editing=true]{width:min(500px,88%)} .fv-studio .fv-chat-log .fv-chat-edit-box{box-sizing:border-box;display:block;width:100%;min-height:44px;max-height:40vh;padding:10px 14px;border:1px solid var(--fv-accent-dim);border-radius:14px;background:#1d3550;color:var(--fv-ink);font:inherit;font-size:14.5px;line-height:22px;resize:none;outline:none;overflow-y:auto} .fv-chat-me>.fv-chat-edit-actions{margin-top:8px;justify-content:flex-end} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 153eb47e..716e4e99 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -1399,7 +1399,9 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () pending = pending.filter(entry => entry.sending || entry.session !== state.session || !ids.has(entry.id)); const localPending = pending.filter(entry => entry.session === state.session && !ids.has(entry.id)); const said = entries.some(entry => entry.type !== 'message' || entry.role === 'user') || localPending.length > 0; - const finished = entries.filter(entry => entry.type === 'job' && state.jobs[entry.job]?.status === 'succeeded' && (state.jobs[entry.job].work || (state.jobs[entry.job].kind === 'image' && state.jobs[entry.job].thumbnail))).at(-1); + // The newest result, a finished video or a finished film, is the one whose Open leads. + const film = entry => entry.type === 'production' && !state.failures[entry.entity] && state.finished?.[entry.entity]?.work; + const finished = entries.filter(entry => film(entry) || (entry.type === 'job' && state.jobs[entry.job]?.status === 'succeeded' && (state.jobs[entry.job].work || (state.jobs[entry.job].kind === 'image' && state.jobs[entry.job].thumbnail)))).at(-1); const wanted = []; if (!entries.length && !localPending.length) wanted.push(['hello', 'hello', null]); let lastUser = null; @@ -1419,8 +1421,9 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const failure = state.failures[entry.entity]; if (failure) wanted.push([`e:${entry.seq}`, 'job', failure]); } else if (entry.type === 'production' || entry.type === 'cast') { - const failure = state.failures[entry.entity]; - if (failure) wanted.push([`s:${entry.seq}`, 'job', failure]); + // A finished film is the same card as any finished video: open it, or edit it with its shots already cut. + const card = state.failures[entry.entity] || (entry.type === 'production' && state.finished?.[entry.entity]); + if (card) wanted.push([`s:${entry.seq}`, 'job', card, {latest: entry === finished}]); } else wanted.push([`s:${entry.seq}`, 'confirm', entry]); } for (const entry of localPending) wanted.push([`m:${entry.id}`, 'user', {...entry, taken: false}]); diff --git a/web/chat_data.js b/web/chat_data.js index 939f5195..de1d379f 100644 --- a/web/chat_data.js +++ b/web/chat_data.js @@ -8,14 +8,14 @@ export async function sendError(response) { try { return (await (response.clone?.() || response).json())?.error || null; } catch { return null; } } -export const emptyChat = () => ({session: undefined, items: new Map(), jobs: {}, failures: {}, confirms: {}, last: 0, read: 0, agent: {}, current: null, active: 0, abandoned: false, stage: null}); +export const emptyChat = () => ({session: undefined, items: new Map(), jobs: {}, failures: {}, finished: {}, confirms: {}, last: 0, read: 0, agent: {}, current: null, active: 0, abandoned: false, stage: null}); // One answer from the server folded into what the page already has: entries add up by their number, jobs are // replaced by id (a card keeps its last state after the server stops listing its job), and so are the answers to its questions. export function chatMerge(state, data) { const items = new Map(state.items); for (const item of data.items || []) if (['message', 'job', 'confirm', 'production', 'cast'].includes(item.type) && Number.isFinite(item.seq)) items.set(item.seq, item); const read = Math.max(state.read, Number(data.read_seq) || 0, ...(data.items || []).filter(item => item.read === true).map(item => item.seq)); - return {session: data.session ?? null, items, jobs: {...state.jobs, ...(data.jobs || {})}, failures: data.failures || {}, confirms: {...state.confirms, ...(data.confirms || {})}, read, + return {session: data.session ?? null, items, jobs: {...state.jobs, ...(data.jobs || {})}, failures: data.failures || {}, finished: data.finished || {}, confirms: {...state.confirms, ...(data.confirms || {})}, read, last: Math.max(state.last, Number(data.last_seq) || 0, ...items.keys()), agent: data.agent || {}, current: data.current || null, active: Number(data.active) || 0, abandoned: data.abandoned === true, stage: typeof data.stage === 'string' ? data.stage : null}; From b9b6a67afcf7f22dbe3ef6145987cb92f41df9f4 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 07:09:55 +0000 Subject: [PATCH 39/43] H3 Image: the same prompt and references are encoded once H3 Image: the same prompt and references are encoded once; later images reuse the saved text conditioning --- freevideo_engine/image_gen/h3_pipeline.py | 11 ++- freevideo_engine/image_gen/prompt_cache.py | 89 ++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 freevideo_engine/image_gen/prompt_cache.py diff --git a/freevideo_engine/image_gen/h3_pipeline.py b/freevideo_engine/image_gen/h3_pipeline.py index e5ac0cd2..86279d62 100644 --- a/freevideo_engine/image_gen/h3_pipeline.py +++ b/freevideo_engine/image_gen/h3_pipeline.py @@ -8,6 +8,7 @@ with the same proportions and are not scaled back. """ import argparse +import sys import tempfile import time from pathlib import Path @@ -204,8 +205,14 @@ def check(): enter('encode') raise ReferencesTooLarge('the references need more memory than this card has, even with every block streamed') tick = time.perf_counter() - cond = _encode(prompt, encoder=encoder, comfy_root=comfy_root, model_paths=model_paths, comfy_python=comfy_python, - vram_gib=vram_gib, ram_gib=ram_gib, vision_images=[vision for _, vision in copies]) + from . import prompt_cache + cond = prompt_cache.encode( + prompt, [vision for _, vision in copies], stats=stats, encoder=encoder, + cache=prompt_cache.open_cache(encoder=encoder, comfy_root=comfy_root, comfy_python=comfy_python or sys.executable, + model_paths=model_paths), + compute=lambda: _encode(prompt, encoder=encoder, comfy_root=comfy_root, model_paths=model_paths, + comfy_python=comfy_python, vram_gib=vram_gib, ram_gib=ram_gib, + vision_images=[vision for _, vision in copies])) stats['encode_s'] = round(time.perf_counter() - tick, 2) check() moments = [] diff --git a/freevideo_engine/image_gen/prompt_cache.py b/freevideo_engine/image_gen/prompt_cache.py new file mode 100644 index 00000000..adb8ea3f --- /dev/null +++ b/freevideo_engine/image_gen/prompt_cache.py @@ -0,0 +1,89 @@ +"""The same prompt is not encoded twice: image conditioning goes through the input cache the video path uses. + +The key is the prompt plus the pixels of every reference, under the identity of the encoder (file content, its code +and libraries, GPU architecture). A hit hands back exactly the tensors the encoder saved, so a cached image equals an +uncached one. Anything that cannot be verified (no installation record, other explicit paths, an unreadable or +damaged entry) encodes as before.""" +import hashlib +import json +from pathlib import Path +import tempfile + +KIND = 'h3-image-conditioning' + + +def reference_key(prompt, images, encoder): + """Prompt, encoder name and the pixels of each reference, in order.""" + rows = [] + for image in images: + picture = image.convert('RGB') + rows.append({'size': list(picture.size), 'sha256': hashlib.sha256(picture.tobytes()).hexdigest()}) + return json.dumps({'version': 1, 'prompt': prompt, 'encoder': encoder, 'references': rows}, + sort_keys=True, ensure_ascii=False) + + +_OPENED = {} + + +def open_cache(*, encoder, comfy_root, comfy_python, model_paths): + """The input cache for this installation, or None when its identity cannot be established. + + Worked out once per process and set of paths: reading the GPU and hashing the encoder's code costs seconds.""" + key = (encoder, str(comfy_root), str(comfy_python), str(model_paths)) + if key not in _OPENED: + _OPENED[key] = _open_cache(encoder=encoder, comfy_root=comfy_root, comfy_python=comfy_python, + model_paths=model_paths) + return _OPENED[key] + + +def _open_cache(*, encoder, comfy_root, comfy_python, model_paths): + try: + from .. import tuning + from ..hardware import detect + from ..input_cache import InputCache + from ..paths import comfy_root as default_root + machine_path = tuning.state_path().parents[1] / 'machine.json' + if not machine_path.is_file(): + return None + machine = json.loads(machine_path.read_text(encoding='utf-8')) + same = (encoder == machine['encoder'] + and Path(comfy_python).absolute() == Path(machine['comfy_python']).absolute() + and Path(comfy_root or default_root()).resolve() == Path(machine['comfy_root']).resolve() + and model_paths and Path(model_paths).resolve() == Path(machine['model_paths']).resolve()) + if not same: + return None + return InputCache(tuning.text_identity(machine, detect()), kind=KIND) + except Exception: + return None + + +def encode(prompt, images, compute, *, cache, encoder, stats=None): + """``compute()`` returns the conditioning dict; it runs only when the cache has no entry for this input.""" + import time + import torch + started = time.perf_counter() + stats = stats if stats is not None else {} + stats['encode_cache'] = 'off' if cache is None else 'miss' + if cache is None: + return compute() + key = reference_key(prompt, images, encoder) + with tempfile.TemporaryDirectory(prefix='freevideo-h3cond-') as directory: + target = Path(directory) / 'cond.pt' + try: + found = cache.get(key, target) + if found is not None: + value = torch.load(target, map_location='cpu', weights_only=False) + if isinstance(value, dict) and 'prompt_embeds' in value: + stats['encode_cache'] = 'hit' + stats['encode_cache_s'] = round(time.perf_counter() - started, 2) + return value + except Exception: + pass + value = compute() + try: + source = Path(directory) / 'save.pt' + torch.save(value, source) + cache.put(key, source, {'success': True, 'kind': KIND}) + except Exception: + pass + return value From 55a9011e2c97ff1545d135783171da205f3cacbd Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 07:13:32 +0000 Subject: [PATCH 40/43] H3 Image: the encoder, DiT stack and VAE stay resident between pictures within the card and... H3 Image: the encoder, DiT stack and VAE stay resident between pictures within the card and memory budget; the plan counts what is kept and the process's own CUDA context --- freevideo_engine/encode.py | 1 + freevideo_engine/encode_worker.py | 32 ++- freevideo_engine/image_gen/h3_pipeline.py | 251 +++++++++++++++++++-- freevideo_engine/image_gen/h3_runner.py | 18 +- freevideo_engine/image_gen/h3_smoke.py | 10 +- freevideo_engine/image_gen/image_stream.py | 35 ++- freevideo_engine/image_gen/service.py | 56 ++++- freevideo_engine/image_gen/worker.py | 7 + freevideo_engine/resident_process.py | 30 ++- freevideo_engine/resident_worker.py | 15 +- 10 files changed, 419 insertions(+), 36 deletions(-) diff --git a/freevideo_engine/encode.py b/freevideo_engine/encode.py index dd125c08..7287e8a1 100644 --- a/freevideo_engine/encode.py +++ b/freevideo_engine/encode.py @@ -37,6 +37,7 @@ def interrupted(signum, frame): 'model_paths': str(args.model_paths.resolve()) if args.model_paths else None, 'output': str(destination), 'gpu_budget_gb': policy.gpu_budget_bytes / 1e9, 'metrics': str(destination.with_suffix('.encoding.json')), + 'release_gpu': bool(getattr(args, 'release_gpu', False)), **({'h3_image_references': [str(Path(p).resolve()) for p in args.h3_image_references]} if getattr(args, 'h3_image_references', None) else {})}) + '\n', encoding='utf-8') env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1]), diff --git a/freevideo_engine/encode_worker.py b/freevideo_engine/encode_worker.py index 0ee1bfbd..ba3c3207 100644 --- a/freevideo_engine/encode_worker.py +++ b/freevideo_engine/encode_worker.py @@ -167,8 +167,14 @@ def start_layer_window(trace, clip, path): def stop_layer_window(trace): """Stop the layer window, once, and keep what it did in the metrics.""" window = getattr(trace, 'layer_window', None) - if window is not None and 'encoder_window' not in trace.data: + if window is None: + return + if 'encoder_window' not in trace.data: trace.data['encoder_window'] = window.close() + # The window's table of layers would keep every encoder layer (and its weights on the card) alive + # for as long as the request's trace lives, after the encoder itself has left. + window.modules = {} + trace.layer_window = None def start_model_read_ahead(trace, request): @@ -592,6 +598,30 @@ def attempt(index, failure): value['early_reference'] = early_reference torch.cuda.synchronize() prompt_encode_seconds = time.perf_counter() - encode_started + if request.get('release_gpu') and resident is not None: + # The caller (a picture request) runs a different model next and needs the card: the encoder leaves + # the GPU, staying resident in host memory when that fits, else it is dropped. + import comfy.model_management as _memory + from .resident_models import GiB as _GiB + from .resident_models import gpu_bytes as _gpu_bytes + # The weights copy back to host memory beside the picture model's own host weights (about 21 GiB). + if resident.ram_fits(_gpu_bytes(clip) + 21 * _GiB + 8 * _GiB): + _memory.unload_all_models() + _memory.soft_empty_cache() + print(json.dumps({'event': 'encoder_left_gpu', 'kept_in_ram': True, + 'gpu_allocated_gib': round(torch.cuda.memory_allocated() / _GiB, 2), + 'gpu_reserved_gib': round(torch.cuda.memory_reserved() / _GiB, 2)}), flush=True) + else: + resident.drop('encoder', 'Picture request needs the card and host memory cannot hold the encoder') + # The local reference would keep the weights on the card. + clip = None + import gc as _gc + _memory.unload_all_models() + _gc.collect() + _memory.soft_empty_cache() + print(json.dumps({'event': 'encoder_left_gpu', 'kept_in_ram': False, + 'gpu_allocated_gib': round(torch.cuda.memory_allocated() / _GiB, 2), + 'gpu_reserved_gib': round(torch.cuda.memory_reserved() / _GiB, 2)}), flush=True) # Keyframe and reference-media encoding read the drive themselves. stop_model_read_ahead(trace) keyframe_metrics = {} diff --git a/freevideo_engine/image_gen/h3_pipeline.py b/freevideo_engine/image_gen/h3_pipeline.py index 86279d62..8dbcd0a3 100644 --- a/freevideo_engine/image_gen/h3_pipeline.py +++ b/freevideo_engine/image_gen/h3_pipeline.py @@ -8,6 +8,7 @@ with the same proportions and are not scaled back. """ import argparse +import os import sys import tempfile import time @@ -37,6 +38,112 @@ def free_host_memory(): empty_host_cache(torch) +class KeptModels: + """The DiT stack and the VAE between requests of one worker. + + Keeping them saves loading the 21 GB of weights and the pinned planes again, so the next request starts + sampling after its encoding. A model is kept only while the host can still hold it without squeezing the + shared encoder and the video model: the cgroup (or the machine) must keep ``HOST_MARGIN`` free after the + weights, and a card below ``MIN_KEEP_VRAM`` gives its resident blocks back to the shared GPU (the host copy + stays). Anything that does not fit is closed, which is what every request did before. + """ + HOST_MARGIN = 10 * 2**30 + MIN_KEEP_VRAM = 12 * 2**30 + budget = None + + def __init__(self): + self.items = {} + + def take(self, role, key): + entry = self.items.pop(role, None) + if entry is None: + return None + if entry[0] != key: + self.close_entry(entry) + return None + return entry[1] + + @staticmethod + def close_entry(entry): + stack = entry[2] + if stack is not None: + stack.close() + + def host_room(self): + from .image_stream import cgroup_pin_cap + cap = cgroup_pin_cap(reserve=0) + if cap is not None: + return cap + try: + import psutil + return psutil.virtual_memory().available + except ImportError: + return 0 + + def keep(self, role, key, value, stack): + """Hold ``value`` for the next request; False when it does not fit (the caller closes it).""" + held = stack.pinned_bytes if stack is not None else 0 + total = torch.cuda.get_device_properties(0).total_memory + # A card that is small, or has only a small part free to this request, keeps nothing on the device. + small = min(total, self.budget or total) < self.MIN_KEEP_VRAM + if role == 'vae' and small: + return False + if role == 'dit' and self.host_room() + held < held + self.HOST_MARGIN: + return False + if role == 'dit': + if small: + stack.set_resident(0) + # The embedders and tables are about 1 GiB on the card; the next encode needs that room. + model = value[0] + for name, child in model.named_children(): + if name != 'blocks': + child.to('cpu') + model.adaln_t_table = model.adaln_t_table.to('cpu') + old = self.items.pop(role, None) + if old is not None: + self.close_entry(old) + self.items[role] = (key, value, stack) + return True + + def close(self): + for entry in list(self.items.values()): + self.close_entry(entry) + self.items.clear() + + +# What a resident encode needs free on the card when the request starts: the encoder's own minimum, the CUDA context +# the resident worker keeps there, and what the picture process needs beside them. +ENCODER_MIN_FREE = 5 * 2**30 +RESIDENT_CONTEXT = int(0.8 * 2**30) +PICTURE_CONTEXT = int(0.75 * 2**30) +PICTURE_SHARE = int(2.2 * 2**30) +RESIDENT_ENCODE_MIN_FREE = ENCODER_MIN_FREE + RESIDENT_CONTEXT + PICTURE_SHARE + + +def resident_encode_decision(free_now): + """(use the resident encoder?, reason) from the free memory the driver reports.""" + if free_now is None: + return True, dict(reason='free memory unreadable; resident kept') + parts = dict(free_gib=round(free_now / 2**30, 2), need_gib=round(RESIDENT_ENCODE_MIN_FREE / 2**30, 2), + encoder_gib=round(ENCODER_MIN_FREE / 2**30, 2), context_gib=round(RESIDENT_CONTEXT / 2**30, 2), + picture_gib=round(PICTURE_SHARE / 2**30, 2)) + if free_now < RESIDENT_ENCODE_MIN_FREE: + return False, dict(parts, reason='free memory below the resident encoder, its context and the picture share') + return True, dict(parts, reason='free memory covers the resident encoder, its context and the picture share') + + +def _driver_free_bytes(): + """Free memory of the first card from the driver's tool; reading it with CUDA would put this process's context on the card + before the encoder asks for its 5 GiB. None when it cannot be read.""" + import subprocess + try: + out = subprocess.run(['nvidia-smi', '--query-gpu=memory.free', '--format=csv,noheader,nounits'], + capture_output=True, text=True, timeout=10, check=True).stdout + return int(float(out.split()[0]) * 2**20) + except (OSError, ValueError, IndexError, subprocess.SubprocessError): + return None + + class Cancelled(Exception): pass @@ -103,8 +210,49 @@ def denoise_floor_bytes(copies, width, height): sequence_rows=reference_sequence_rows(copies))['denoise'] -def _encode(prompt, *, encoder, comfy_root, comfy_python, model_paths, vram_gib, ram_gib, vision_images=()): + + +def _tensor_bytes(value): + """Bytes of the tensors held by a module, or by nested tuples, lists and dicts of them (a decoder object).""" + if isinstance(value, torch.Tensor): + return value.numel() * value.element_size() if value.is_cuda else 0 + if hasattr(value, 'parameters'): + return sum(_tensor_bytes(t) for t in value.parameters()) + if isinstance(value, dict): + value = value.values() + elif hasattr(value, '__dict__') and not isinstance(value, (list, tuple)): + value = vars(value).values() + if isinstance(value, (list, tuple)) or hasattr(value, '__iter__') and not isinstance(value, (str, bytes)): + return sum(_tensor_bytes(v) for v in value) + return 0 + + +def _encode(prompt, *, encoder, comfy_root, comfy_python, model_paths, vram_gib, ram_gib, vision_images=(), + resident_endpoint=None): """Run the shared text encoder in its own process and return the saved conditioning.""" + import os + import sys + from ..resident_process import ENV + # With the video worker alive the encoder is taken from (and left in) its ModelBank; a worker that is gone + # makes ``child`` fall back to the short-lived process by itself. + previous = os.environ.get(ENV) + if resident_endpoint: + os.environ[ENV] = resident_endpoint + try: + return _encode_once(prompt, encoder, comfy_root, comfy_python, model_paths, vram_gib, ram_gib, vision_images) + except BaseException as error: + # The caller's classifier keeps only a code; the text of the failure goes to the worker's log. + print('H3 encode failed: %s: %s' % (type(error).__name__, str(error)[-2000:]), file=sys.stderr, flush=True) + raise + finally: + if resident_endpoint: + if previous is None: + os.environ.pop(ENV, None) + else: + os.environ[ENV] = previous + + +def _encode_once(prompt, encoder, comfy_root, comfy_python, model_paths, vram_gib, ram_gib, vision_images=()): from .. import encode as encode_command import sys with tempfile.TemporaryDirectory(prefix='freevideo-h3img-') as directory: @@ -119,7 +267,7 @@ def _encode(prompt, *, encoder, comfy_root, comfy_python, model_paths, vram_gib, prompt_file=prompt_file, out=out, encoder=encoder, comfy_root=Path(comfy_root) if comfy_root else None, model_paths=Path(model_paths) if model_paths else None, comfy_python=comfy_python or sys.executable, vram_gib=vram_gib, ram_gib=ram_gib, attention='auto', - gpu_reserve_gib=None, ram_reserve_gib=None, h3_image_references=pictures)) + gpu_reserve_gib=None, ram_reserve_gib=None, release_gpu=True, h3_image_references=pictures)) return torch.load(out, map_location='cpu', weights_only=False) @@ -173,7 +321,7 @@ def reference_moments(copies, vae_path, device, *, cache=True, cache_root=None, def generate(prompt, width, height, seed, references=None, progress_cb=None, cancel=None, *, dit_path, vae_path, encoder='qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors', comfy_root=None, comfy_python=None, model_paths=None, vram_gib=None, ram_gib=None, resident=None, cpu_noise=False, stats=None, mapped=True, - preview=None, enter=None, reference_cache=True, reference_cache_root=None): + preview=None, enter=None, reference_cache=True, reference_cache_root=None, resident_endpoint=None, kept=None): """Return a PIL image for the prompt. ``progress_cb(stage, done, total)`` is called with stage in encode, denoise, decode; @@ -184,6 +332,8 @@ def generate(prompt, width, height, seed, references=None, progress_cb=None, can ``preview`` is the sampler's ``preview_callback`` (see ``step_preview``); it never changes the picture. ``enter(stage)`` names the stage a failure belongs to without moving the progress: the DiT loads after the text, so a load error would otherwise be filed under the text stage. + ``resident_endpoint`` is the video worker whose resident encoder does the text encoding. ``kept`` (a + ``KeptModels``) holds the DiT stack and the decoder between requests. """ references = list(references or []) if len(references) > MAX_REFERENCES: @@ -204,6 +354,16 @@ def check(): if enter is not None: enter('encode') raise ReferencesTooLarge('the references need more memory than this card has, even with every block streamed') + shared_card = bool(resident_endpoint) + if resident_endpoint: + # The resident encoder worker keeps its own CUDA context (about 0.8 GiB) on the card for good and encoding needs + # 5 GiB: a card with less free than that plus the picture process's share encodes in a short process, as before. + free_now = _driver_free_bytes() + use_resident, why = resident_encode_decision(free_now) + stats['encode_resident_decision'] = why + if not use_resident: + stats['encode_resident'] = False + resident_endpoint = None tick = time.perf_counter() from . import prompt_cache cond = prompt_cache.encode( @@ -212,7 +372,7 @@ def check(): model_paths=model_paths), compute=lambda: _encode(prompt, encoder=encoder, comfy_root=comfy_root, model_paths=model_paths, comfy_python=comfy_python, vram_gib=vram_gib, ram_gib=ram_gib, - vision_images=[vision for _, vision in copies])) + vision_images=[vision for _, vision in copies], resident_endpoint=resident_endpoint)) stats['encode_s'] = round(time.perf_counter() - tick, 2) check() moments = [] @@ -237,24 +397,61 @@ def check(): from .h3_vae import H3ImageDecoder, to_unit from .image_policy import ImageShape, plan_image - from .image_stream import StreamedStack + from .image_stream import StreamedStack, checkpoint_layer_source from PIL import Image torch.set_grad_enabled(False) device = torch.device('cuda') total = torch.cuda.get_device_properties(0).total_memory budget = card_budget(vram_gib) + if vram_gib: + # A capped card's limit includes this process's own CUDA context, which the allocator limit does not count. + budget -= PICTURE_CONTEXT + if shared_card: + # The resident video worker still holds its text encoder on the card after encoding: plan against what + # the card really has free plus what this process already holds, not against its whole size. + free, _ = torch.cuda.mem_get_info() + reserved = torch.cuda.memory_reserved() + budget = min(budget, int(free + reserved)) + # A decoder kept from the previous picture is not part of the next denoise plan. + vae_held = 0 + if kept is not None and 'vae' in kept.items: + vae_held = _tensor_bytes(kept.items['vae'][1]) + budget -= vae_held + # budget = free at plan time + this process's cached memory (reserved) - the kept decoder + stats['budget_parts_gib'] = dict(free=round(free / 2**30, 2), reserved=round(reserved / 2**30, 2), + kept_vae=round(vae_held / 2**30, 2), + allocated=round(torch.cuda.memory_allocated() / 2**30, 2)) + stats['budget_gib'] = round(budget / 2**30, 2) + if kept is not None: + kept.budget = budget if vram_gib: torch.cuda.set_per_process_memory_fraction(min(1.0, budget / total)) canvas_h, canvas_w = h3_dit.canvas_size(height, width) if enter is not None: enter('load') tick = time.perf_counter() - model = load_h3_dit(str(dit_path), 'cpu', mapped=mapped) - for name, child in model.named_children(): - if name != 'blocks': - child.to(device) - model.adaln_t_table = model.adaln_t_table.to(device) - stack = StreamedStack(model.blocks, name='h3', device=device) + held = kept.take('dit', str(dit_path)) if kept is not None else None + if held is not None: + model, stack = held + stats['dit_kept'] = True + # A small card gave the non-block parts back to the host after the last picture. + for name, child in model.named_children(): + if name != 'blocks': + child.to(device) + model.adaln_t_table = model.adaln_t_table.to(device) + else: + model = load_h3_dit(str(dit_path), 'cpu', mapped=mapped) + for name, child in model.named_children(): + if name != 'blocks': + child.to(device) + model.adaln_t_table = model.adaln_t_table.to(device) + source = checkpoint_layer_source(str(dit_path), list(model.blocks)) if mapped else None + # Layers the host cannot keep locked are read from the file ahead of their + # use through the staging buffer, one device slot (see LayerOffloader). + stack = (StreamedStack(model.blocks, name='h3', device=device, weight_source=source, + prefetch=os.environ.get('FREEVIDEO_H3_DEVICE_PREFETCH') == '1', + host_prefetch=True) if source is not None + else StreamedStack(model.blocks, name='h3', device=device)) shape = ImageShape.measure(list(model.blocks), None) shape = ImageShape(**{**shape.__dict__, 'steps': 6}) plan = plan_image(gpu_budget_bytes=budget, width=canvas_w, height=canvas_h, shape=shape, @@ -266,7 +463,7 @@ def check(): generator = torch.Generator('cpu' if cpu_noise else device).manual_seed(seed) # The released order: each reference's posterior sample, then the image's noise, then each reference's noise. from .h3_vae import reference_latents - held = [reference_latents(m.to(device), generator, 'cpu' if cpu_noise else None) for m in moments] + ref_latents = [reference_latents(m.to(device), generator, 'cpu' if cpu_noise else None) for m in moments] def step(i, n): check() @@ -276,23 +473,41 @@ def step(i, n): try: latents = h3_dit.sample(model, cond['prompt_embeds'], cond['text_token_tags'], canvas_h // 16, canvas_w // 16, generator, noise_device='cpu' if cpu_noise else None, step_callback=step, - preview_callback=preview, references=held) + preview_callback=preview, references=ref_latents) torch.cuda.synchronize() stats['sample_s'] = round(time.perf_counter() - tick, 2) stats['denoise_peak_gib'] = round(torch.cuda.max_memory_allocated() / 2**30, 2) finally: - stack.close() + if kept is not None and resident is None and plan.dit_kept_layers is not None and stack.offloader.at_boundary: + # The decode stage was planned with this many blocks on the card. + stack.set_resident(plan.dit_kept_layers) + if (kept is not None and stack.offloader.at_boundary + and kept.keep('dit', str(dit_path), (model, stack), stack)): + stats['dit_kept_after'] = True + else: + stack.close() del model, stack - free_host_memory() + # Nothing is cleared while the weights stay: ModelBank-style room checks (KeptModels.keep) decided that. + if kept is None or 'dit' not in kept.items: + free_host_memory() report('denoise', 6, 6) check() report('decode', 0, 1) torch.cuda.reset_peak_memory_stats() - decoder = H3ImageDecoder.load(str(vae_path), device) + decoder = kept.take('vae', str(vae_path)) if kept is not None else None + stats['vae_kept'] = decoder is not None + if decoder is None: + decoder = H3ImageDecoder.load(str(vae_path), device) pixels = to_unit(decoder.decode(latents.to(device)[None], normalized=True)) image = Image.fromarray((pixels[0].clamp(0, 1) * 255).round().byte().permute(1, 2, 0).cpu().numpy()) stats['decode_peak_gib'] = round(torch.cuda.max_memory_allocated() / 2**30, 2) - del decoder - free_host_memory() + if kept is None or not kept.keep('vae', str(vae_path), decoder, None): + del decoder + free_host_memory() report('decode', 1, 1) + if shared_card: + # The next picture's text encoding runs in another process on this card: give back the allocator's cache. + torch.cuda.empty_cache() + stats['held_after_gib'] = dict(allocated=round(torch.cuda.memory_allocated() / 2**30, 2), + reserved=round(torch.cuda.memory_reserved() / 2**30, 2)) return image diff --git a/freevideo_engine/image_gen/h3_runner.py b/freevideo_engine/image_gen/h3_runner.py index bcbc55ca..f28e3061 100644 --- a/freevideo_engine/image_gen/h3_runner.py +++ b/freevideo_engine/image_gen/h3_runner.py @@ -61,6 +61,7 @@ def __init__(self, pack_dir, *, device='cuda', progress=None): self.files = files(self.model_root) self.last_plan = None self.last_stats = None + self.kept = None if progress is not None: progress('load', 1.) @@ -92,18 +93,30 @@ def report(stage, done, total): stats = {} self.last_stats = dict(h3=stats) + if self.kept is None: + self.kept = h3_pipeline.KeptModels() try: return h3_pipeline.generate( prompt, width, height, seed, reference_images or None, report, (lambda: bool(cancel and cancel())), dit_path=self.files['dit'], vae_path=self.files['vae'], encoder=setup.get('encoder'), comfy_root=setup.get('comfy_root'), comfy_python=setup.get('comfy_python'), model_paths=setup.get('model_paths'), vram_gib=setup.get('vram_gib'), ram_gib=setup.get('ram_gib'), - stats=stats, preview=_previewer(preview), enter=stage) + stats=stats, preview=_previewer(preview), enter=stage, + resident_endpoint=setup.get('resident_endpoint'), kept=self.kept) except h3_pipeline.Cancelled: raise InterruptedError('cancelled') from None finally: self.last_plan = dict(seed=seed, **{key: stats[key] for key in ('canvas', 'resident', 'planned_peak_gib', 'host_mode') if key in stats}) + def idle(self, should_stop): + """Prepare the next request after a picture: refill the card with the blocks the plan keeps, then the + read-ahead (``h3_pipeline.warm``, when present). Stops at once when ``should_stop()`` is true.""" + if self.kept is None or should_stop(): + return + warm = getattr(__import__(__package__ + '.h3_pipeline', fromlist=['x']), 'warm', None) + if warm is not None: + warm(self.files['dit'], kept=self.kept, should_stop=should_stop) + def at_boundary(self): return True @@ -111,4 +124,7 @@ def stats(self): return dict(plan=self.last_plan, **(self.last_stats or {})) def close(self): + if self.kept is not None: + self.kept.close() + self.kept = None self.last_plan = self.last_stats = None diff --git a/freevideo_engine/image_gen/h3_smoke.py b/freevideo_engine/image_gen/h3_smoke.py index 42015461..226fd014 100644 --- a/freevideo_engine/image_gen/h3_smoke.py +++ b/freevideo_engine/image_gen/h3_smoke.py @@ -15,7 +15,7 @@ from . import h3_dit from .h3_weights import load_h3_dit -from .image_stream import StreamedStack +from .image_stream import StreamedStack, checkpoint_layer_source def main(argv=None): @@ -33,6 +33,7 @@ def main(argv=None): parser.add_argument('--resident', type=int, default=0) parser.add_argument('--no-mapped', action='store_true', help='read the checkpoint into private memory instead of mapping it') parser.add_argument('--host-prefetch', choices=('auto', 'off'), default='auto') + parser.add_argument('--direct', choices=('on', 'off'), default='on', help='read layers the host cannot pin from the file by direct reads') args = parser.parse_args(argv) torch.set_grad_enabled(False) device = torch.device('cuda') @@ -46,8 +47,9 @@ def main(argv=None): if name != 'blocks': child.to(device) model.adaln_t_table = model.adaln_t_table.to(device) - stack = StreamedStack(model.blocks, name='h3', device=device, - host_prefetch=False if args.host_prefetch == 'off' else None) + source = None if args.no_mapped or args.direct == 'off' else checkpoint_layer_source(args.dit, list(model.blocks)) + stack = StreamedStack(model.blocks, weight_source=source, prefetch=source is None, name='h3', device=device, + host_prefetch=True if source is not None else (False if args.host_prefetch == 'off' else None)) plan = None if args.resident < 0: from .image_policy import ImageShape, plan_image @@ -82,7 +84,7 @@ def main(argv=None): memory = {key: status.get(key, '').strip() for key in ('VmHWM', 'VmSwap')} print(json.dumps(dict(memory=memory, stack=offload)), flush=True) print(json.dumps(dict(load_s=round(loaded - started, 1), prepare_s=round(prepared - loaded, 1), - sample_s=round(done - prepared, 1), step_s=round((done - prepared) / 6, 2), resident=args.resident, mapped=not args.no_mapped, + sample_s=round(done - prepared, 1), device_read_gib=round(int(dict(l.split(': ') for l in open('/proc/self/io'))['read_bytes']) / 2**30, 1), step_s=round((done - prepared) / 6, 2), resident=args.resident, mapped=not args.no_mapped, planned_peak_gib=None if plan is None else round(plan.peak_allocated_bytes / 2**30, 2), canvas=[height, width], denoise_peak_gib=round(denoise_peak / 2**30, 2), decode_peak_gib=round(torch.cuda.max_memory_allocated() / 2**30, 2), reserved_gib=round(torch.cuda.max_memory_reserved() / 2**30, 2), diff --git a/freevideo_engine/image_gen/image_stream.py b/freevideo_engine/image_gen/image_stream.py index 25051d6c..e551a97f 100644 --- a/freevideo_engine/image_gen/image_stream.py +++ b/freevideo_engine/image_gen/image_stream.py @@ -29,11 +29,41 @@ def cgroup_pin_cap(reserve=IMAGE_WORKING_RESERVE): return None if room is None else max(0, room - reserve) +def checkpoint_layer_source(path, layers, prefix='blocks.'): + """A direct-read source of the checkpoint for the streamed layers, or None. + + A layer that does not stay pinned is read from the file by page faults + when its weights are views of the mapped checkpoint: one stream at about + a third of the drive's sequential rate, which was the whole cost of a + 16 GB machine's step. The video engine's reader takes the bytes with + large direct reads, ahead of the layer that is computing. The module + names its int8 weights ``weight_int8`` where the file says ``weight``; + a name the file does not hold under either spelling leaves the layers + on the mapped views. + """ + from ..streamed_weights import SafetensorLayers + try: + source = SafetensorLayers([path], [f'{prefix}{index}.' for index in range(len(layers))], direct_read=True) + except (OSError, ValueError, KeyError): + return None + for index, layer in enumerate(layers): + for name, _ in list(layer.named_parameters()) + list(layer.named_buffers()): + key = f'{prefix}{index}.{name}' + if key in source.keys: + continue + stored = key.replace('weight_int8', 'weight') + if stored not in source.keys: + return None + source.keys[key] = source.keys[stored] + source.byte_ranges[key] = source.byte_ranges[stored] + return source + + class StreamedStack: """``layers`` hold frozen CPU weights; they are moved to the card on use.""" def __init__(self, layers, *, name, device='cuda', pin=True, max_pin_bytes=None, prefetch=True, host_prefetch=None, - progress=None): + progress=None, weight_source=None): self.name = name self.layers = list(layers) if not self.layers: @@ -50,7 +80,8 @@ def __init__(self, layers, *, name, device='cuda', pin=True, max_pin_bytes=None, if cap is not None: max_pin_bytes = cap if max_pin_bytes is None else min(max_pin_bytes, cap) self.pinned_bytes = pin_layer_weights(self.layers, max_bytes=max_pin_bytes, **pin_options) if pin else 0 - self.offloader = LayerOffloader(self.layers, device=device, prefetch=prefetch, host_prefetch=host_prefetch) + self.offloader = LayerOffloader(self.layers, device=device, prefetch=prefetch, host_prefetch=host_prefetch, + weight_source=weight_source) self.layer_bytes = max(sum(count * parameter.element_size() for parameter, _, _, _, count, _ in entries) for entries in self.offloader.layout) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 8a5cfa70..aedbbab8 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -650,7 +650,8 @@ def prepare(self, action, value, context, *, waiting=False): if h3: # What the worker needs to run the video text encoder for this prompt and to plan against the card. value['h3'] = dict(encoder=machine.get('encoder'), comfy_root=machine.get('comfy_root'), - comfy_python=machine.get('comfy_python'), model_paths=machine.get('model_paths')) + comfy_python=machine.get('comfy_python'), model_paths=machine.get('model_paths'), + resident_endpoint=self.resident_endpoint()) context.update_request(value) # H3 Image plans its own residency in the worker; the Qwen plan below does not describe it. context.plan = None if h3 else self.preflight(machine, value, fit=not waiting, reading=context.gpu_reading) @@ -1049,6 +1050,14 @@ def close(self): if queue_thread is not None and queue_thread is not threading.current_thread(): queue_thread.join() + def resident_endpoint(self): + """The video worker's endpoint when it is alive, so the image worker's text encoding can reuse its resident encoder.""" + from .. import resident_process + owner = resident_process.OWNER + if owner.process is not None and owner.process.poll() is None and owner.endpoint: + return str(owner.endpoint) + return None + def environment(self, root): from ..comfy_bridge import source_root from ..comfy_environment import isolated_environment @@ -1059,6 +1068,21 @@ def environment(self, root): env.pop(name, None) return env + @staticmethod + def h3_resident(machine): + """Whether H3 Image is installed on a card whose encoder the resident worker can hold.""" + from ..comfy_bridge import source_root + return (sys.platform != 'darwin' and machine.get('device_backend', 'cuda') == 'cuda' + and image_pack.ready(machine['model_root'], group=H3_GROUP) + and (Path(source_root())/'freevideo_engine'/'resident_worker.py').is_file()) + + @staticmethod + def start_resident(root, machine, env): + """Start (or reuse) the resident worker for the picture session; it encodes text and needs no video weights.""" + from ..comfy_bridge import source_root + from .. import resident_process + return resident_process.OWNER.start(root, source_root(), machine['comfy_python'], env) + def spawn(self, root, machine, env, descriptor): from .. import processes command = processes.module_command('freevideo_engine.image_gen.worker', image_pack.base(machine['model_root']), @@ -1085,6 +1109,7 @@ def work(self, root, machine): from .. import processes, resident_process self.habit_root = root session = None + started_resident = False failure = 'worker_exit' failure_error = None try: @@ -1113,14 +1138,33 @@ def work(self, root, machine): return env = self.environment(root) owner = resident_process.OWNER + # H3 Image encodes in the resident worker, which then keeps the text encoder between pictures. + keep_encoder = self.h3_resident(machine) if owner.process is not None and owner.process.poll() is None and owner.endpoint: env[resident_process.ENV] = str(owner.endpoint) + held = resident_process.status(env) if keep_encoder else None + # While the card holds only the encoder nothing is given back, so a picture finds it ready. + # Video models in the worker compete with the picture for the card, so those go. + foreign = not isinstance(held, dict) or any( + row.get('role') != 'encoder' for row in held.get('models') or []) try: - resident_process.release_idle_cache(env, descriptor) - resident_process.OWNER.record(action='released', reason='image_model') + if not keep_encoder or foreign: + try: + resident_process.release_idle_cache(env, descriptor, keep=('encoder',) if keep_encoder else ()) + except resident_process.SessionRestarted: + raise + except RuntimeError: + if not keep_encoder: + raise + # A worker from before partial release: give everything back as before. + resident_process.release_idle_cache(env, descriptor) + resident_process.OWNER.record(action='trimmed' if keep_encoder else 'released', reason='image_model') except resident_process.SessionRestarted: pass env.pop(resident_process.ENV, None) + if keep_encoder: + env[resident_process.ENV] = self.start_resident(root, machine, env) + started_resident = True if self.session_stop.is_set(): return if self.video_busy(): @@ -1184,6 +1228,9 @@ def respawn(): processes.stop(session.process, grace=5) session.process.wait() session.close() + if started_resident and not resident_process.OWNER.active: + # The picture session started this worker only to hold the encoder between pictures. + resident_process.OWNER.release('image_session_end') except BlockingIOError as error: failure = 'busy_video' failure_error = error @@ -1300,6 +1347,9 @@ def infer(self, session, value): value['keep_seconds'] = keep.keep_seconds(habit) context.report_id = uuid.uuid4().hex self.start_forecast(context, value, habit) + if isinstance(value.get('h3'), dict): + # The picture session may have started the resident worker after this request was prepared. + value['h3']['resident_endpoint'] = self.resident_endpoint() loading = True limit = H3_INFERENCE_TIMEOUT if value.get('model') == image_model.H3 else INFERENCE_TIMEOUT try: diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index b1b0afdf..a380b4ef 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -265,6 +265,13 @@ def generate(seed): if result is not None: send(**result) wait, crowding = idle_seconds, None + if result is not None and result.get('phase') == 'result' and runner is not None and hasattr(runner, 'idle'): + # Prepare the next picture until a request, a closed pipe or a cancel arrives. + try: + with redirect_stdout(sys.stderr): + runner.idle(lambda: not inbox.rows.empty() or inbox.closed.is_set() or inbox.cancel.is_set()) + except Exception as error: + print('image worker prepare failed: %s' % type(error).__name__, file=sys.stderr, flush=True) if result is not None and result.get('phase') == 'result' and runner is not None: wait = keep.wait_seconds(value.get('keep_seconds'), runner, idle_seconds) if wait > idle_seconds: diff --git a/freevideo_engine/resident_process.py b/freevideo_engine/resident_process.py index 9e99cc55..897b7c71 100644 --- a/freevideo_engine/resident_process.py +++ b/freevideo_engine/resident_process.py @@ -207,7 +207,7 @@ class RemoteProcess: """Popen-shaped active request; pid is the measured GPU worker, not an RPC shim.""" def __init__(self, endpoint, command, environment, descriptor, log, *, required_capabilities=(), operation='run'): import psutil - if operation not in ('run', 'release'): + if operation not in ('run', 'release', 'trim'): raise ValueError('Invalid resident operation') self.connection, hello = connect(endpoint) self.pid, self.created = hello['pid'], hello['created'] @@ -331,15 +331,33 @@ def stop_request(self, grace=10): self.connection.close() -def release_idle_cache(environment, descriptor): +def release_idle_cache(environment, descriptor, keep=()): """Release only our authenticated idle process, under the runtime lease. + With ``keep`` (model roles such as ``'encoder'``) the process stays and gives back every other + cached model, so a picture request can take the card's memory without losing the text encoder. + Used once when cached models prevent admitting the next stage. Exiting the process releases encoder mappings, CUDA allocations and WDDM commit together. Conditioning already saved on disk is unaffected; no generation is repeated. """ endpoint = environment[ENV] log = Path(endpoint).with_name('release-%d.log' % time.time_ns()) + if keep: + keep = sorted(set(keep)) + worker = RemoteProcess(endpoint, ['trim-idle-cache', '--keep', *keep], environment, + descriptor, log, required_capabilities=('idle-cache-trim',), operation='trim') + try: + code = worker.wait(timeout=30.) + if code: + raise RuntimeError('Idle cache trim failed with exit %s' % code) + return dict(released=False, kept=keep, pid=worker.pid) + except BaseException: + if not worker.settled: + worker.stop_request(grace=1.) + raise + finally: + worker.connection.close() worker = RemoteProcess(endpoint, ['release-idle-cache'], environment, descriptor, log, required_capabilities=('idle-cache-release',), operation='release') try: @@ -711,7 +729,7 @@ def worker_memory(pid): OWNER = SessionOwner() -def serve(endpoint, runner, snapshot): +def serve(endpoint, runner, snapshot, trim=None): """Shared transport with a CPU-injectable runner for lifecycle tests.""" from multiprocessing.connection import Listener import psutil @@ -731,12 +749,12 @@ def owner_watch(): with listener.accept() as connection: state = snapshot() send(connection, dict(hello, allocator_config=state.get('allocator_config'), - capabilities=list(state.get('capabilities', []))+['idle-cache-release'])) + capabilities=list(state.get('capabilities', []))+['idle-cache-release']+(['idle-cache-trim'] if trim else []))) request = receive(connection, 10.) if request.get('command') == 'status': send(connection, dict(snapshot(), idle=True)) continue - if request.get('command') not in ('run', 'release'): + if request.get('command') not in ('run', 'release', 'trim'): raise ValueError('Unknown resident worker command') save(ready_path, dict(hello, idle=False)) descriptor = None @@ -764,7 +782,7 @@ def request_watch(): send(connection, {'status': 'accepted'}) accepted = True watcher.start() - code = runner(request) if request['command'] == 'run' else 0 + code = runner(request) if request['command'] == 'run' else trim(request) if request['command'] == 'trim' else 0 except BaseException as error: if not accepted: failure = dict(status='rejected', phase=phase, diff --git a/freevideo_engine/resident_worker.py b/freevideo_engine/resident_worker.py index 55467acb..16821745 100644 --- a/freevideo_engine/resident_worker.py +++ b/freevideo_engine/resident_worker.py @@ -193,7 +193,20 @@ def stage(name): log.flush() os._exit(75) return 1 - serve(args.endpoint, run, snapshot) + def trim(message): + """Give back every cached model except the roles named after --keep; the process and what it keeps stay.""" + command = message['argv'] + keep = set(command[command.index('--keep')+1:]) if '--keep' in command else set() + if bank is not None: + for role in list(bank.entries): + if role not in keep: + bank.drop(role, 'Another model needs the card') + gc.collect() + torch = sys.modules.get('torch') + if torch is not None and torch.cuda.is_initialized(): + torch.cuda.empty_cache() + return 0 + serve(args.endpoint, run, snapshot, trim) if __name__ == '__main__': From 8a17cbd6d47badb64b81909bfaf5b8b259e33da0 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 07:33:13 +0000 Subject: [PATCH 41/43] H3 Image: kept models give the card back before the reference encoder loads, and every reference... H3 Image: kept models give the card back before the reference encoder loads, and every reference memory check uses the budget less the CUDA context --- freevideo_engine/image_gen/h3_pipeline.py | 47 ++++++++++++++++------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/freevideo_engine/image_gen/h3_pipeline.py b/freevideo_engine/image_gen/h3_pipeline.py index 8dbcd0a3..5b45b19d 100644 --- a/freevideo_engine/image_gen/h3_pipeline.py +++ b/freevideo_engine/image_gen/h3_pipeline.py @@ -105,6 +105,18 @@ def keep(self, role, key, value, stack): self.items[role] = (key, value, stack) return True + def give_back_device(self): + """Hand the card back before a stage that was not planned beside the kept models (the reference encoder): + the kept DiT stack keeps its host copy with no block on the card, and the kept decoder is let go.""" + dit = self.items.get('dit') + if dit is not None: + dit[2].set_resident(0) + vae = self.items.pop('vae', None) + if vae is not None: + self.close_entry(vae) + if dit is not None or vae is not None: + torch.cuda.empty_cache() + def close(self): for entry in list(self.items.values()): self.close_entry(entry) @@ -186,10 +198,10 @@ def reference_sequence_rows(copies): return rows -# What the streamed-only floor must leave free for the request to be tried at all. Under an 8 GiB cap 16 photos -# (floor 7.99 GiB) ran out in the first step with 0.84 GiB held in pieces, while 4 large references (floor 6.72) -# finished; half the planner's smallest margin separates the two. -REFUSE_ALLOWANCE = 0.75 * 2**30 +# What the streamed-only floor must leave free, beyond this process's CUDA context, for the request to be tried at +# all. Under an 8 GiB cap 16 photos (floor 7.99 GiB) ran out in the first step with 0.84 GiB held in pieces; with the +# context taken off the cap (7.25 GiB usable) 10 photos (floor 6.93 GiB, 0.32 GiB to spare) finished. +REFUSE_ALLOWANCE = 0.25 * 2**30 class ReferencesTooLarge(MemoryError): @@ -197,9 +209,15 @@ class ReferencesTooLarge(MemoryError): def card_budget(vram_gib): - """Bytes of the card this request may use: the caller's limit, else the whole card.""" + """Bytes of the card this request may use: the caller's limit less this process's CUDA context (which the + allocator limit does not count), else the whole card.""" total = torch.cuda.get_device_properties(0).total_memory - return int(min(vram_gib * 2**30, total)) if vram_gib else total + return int(min(vram_gib * 2**30, total)) - PICTURE_CONTEXT if vram_gib else total + + +def usable_bytes(vram_gib): + """What the allocator can really have: the capped budget, or the whole card less this process's CUDA context.""" + return card_budget(vram_gib) if vram_gib else card_budget(None) - PICTURE_CONTEXT def denoise_floor_bytes(copies, width, height): @@ -271,9 +289,9 @@ def _encode_once(prompt, encoder, comfy_root, comfy_python, model_paths, vram_gi return torch.load(out, map_location='cpu', weights_only=False) -def reference_moments(copies, vae_path, device, *, cache=True, cache_root=None, stats=None): +def reference_moments(copies, vae_path, device, *, cache=True, cache_root=None, stats=None, before_load=None): """Posterior moments ([1, 48, h, w] float32 on the CPU) of each reference's VAE copy, read from or kept in the - input cache the video references use; the encoder loads only when one is missing.""" + input cache the video references use; the encoder loads only when one is missing, after ``before_load()``.""" import hashlib import numpy as np from .h3_vae import H3ImageEncoder, PIXEL_MEAN, PIXEL_STD @@ -298,6 +316,8 @@ def reference_moments(copies, vae_path, device, *, cache=True, cache_root=None, moments = _cached_latent(store, key, work) if moments is None: if encoder is None: + if before_load is not None: + before_load() encoder = H3ImageEncoder.load(str(vae_path), device) tensor = torch.from_numpy(np.ascontiguousarray(pixels)).to(device).permute(2, 0, 1)[None].float() mean = torch.tensor(PIXEL_MEAN, device=device).view(1, 3, 1, 1) @@ -348,7 +368,7 @@ def check(): check() report('encode', 0, 1) copies = reference_copies(references) - if copies and denoise_floor_bytes(copies, width, height) + REFUSE_ALLOWANCE > card_budget(vram_gib): + if copies and denoise_floor_bytes(copies, width, height) + REFUSE_ALLOWANCE > usable_bytes(vram_gib): # Refused before the text encoder starts: otherwise the request spends a minute or more there and runs out # in the first step. Filed under the reference stage, where the page asks for fewer references. if enter is not None: @@ -383,10 +403,12 @@ def check(): tick = time.perf_counter() if vram_gib: total = torch.cuda.get_device_properties(0).total_memory - torch.cuda.set_per_process_memory_fraction(min(1.0, vram_gib * 2**30 / total)) + torch.cuda.set_per_process_memory_fraction(min(1.0, card_budget(vram_gib) / total)) torch.cuda.reset_peak_memory_stats() + # The encoder's peak (about 3 GiB) was never planned beside the blocks and decoder kept from the last picture. moments = reference_moments(copies, vae_path, torch.device('cuda'), cache=reference_cache, - cache_root=reference_cache_root, stats=stats) + cache_root=reference_cache_root, stats=stats, + before_load=kept.give_back_device if kept is not None else None) free_host_memory() stats.update(reference_encode_s=round(time.perf_counter() - tick, 2), reference_encode_peak_gib=round(torch.cuda.max_memory_allocated() / 2**30, 2), @@ -403,9 +425,6 @@ def check(): device = torch.device('cuda') total = torch.cuda.get_device_properties(0).total_memory budget = card_budget(vram_gib) - if vram_gib: - # A capped card's limit includes this process's own CUDA context, which the allocator limit does not count. - budget -= PICTURE_CONTEXT if shared_card: # The resident video worker still holds its text encoder on the card after encoding: plan against what # the card really has free plus what this process already holds, not against its whole size. From 94a90be61a775fc21c5966b4ee0221666b50f016 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 07:52:54 +0000 Subject: [PATCH 42/43] Node tests follow the offline repair import, the Detect & review repair hint, Chinese node... Node tests follow the offline repair import, the Detect & review repair hint, Chinese node labels and the progress label fake DOM --- scripts/test_freevideo_entry.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 4257aa16..83fd73e0 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -66,7 +66,8 @@ test('main browser entry registers both views and preserves node hooks', async t const dependencies = { app, api, uploadMedia, outputDownloadURL, regenerateResult, upscaleResult, createSecondPassQueue, enableNode, referenceInputs, mediaRows, expandCast, cleanAudioUse, cleanOptionalPrompt, cleanOptionalWidgets, optionalInputValue, createCastLibrary: options => { - const cast = {element: new Element('cast'), options, destroyed: false, destroy() { this.destroyed = true; }}; + // notice: the could-not-read line the real library returns for the page to place. + const cast = {element: new Element('cast'), notice: new Element('notice'), options, destroyed: false, destroy() { this.destroyed = true; }}; casts.push(cast); return cast; }, secondPassAction: () => new Element('button'), @@ -271,8 +272,10 @@ test('main browser entry registers both views and preserves node hooks', async t ['step_cache', 'Step cache', '步间缓存', 'none', '复用相近采样步之间的计算,更快完成,结果会有细微变化。'], ['upscale_factor', 'Upscale factor', '放大倍数', '2', '二次采样时成片相对首遍的放大倍数。'], - ['voice_reference', 'Voice reference', 'Voice reference', true], - ['hide_tail', 'Hide dialogue tail', 'Hide dialogue tail', true], + ['voice_reference', 'Voice reference', '声音参考', true, + '接着生成对白时,沿用原视频开头的声音,让前后声音一致。'], + ['hide_tail', 'Hide dialogue tail', '隐藏对白尾段', true, '多生成 17 帧后丢弃,让对白在前后两段之间连贯。'], + ['allow_empty_prompt', 'Allow empty prompt', '允许空描述', false, '描述为空时直接生成,不再提示。'], ]; const node = new LocalizedGenerateNode(); node.widgets = rows.map(([name, label, , value]) => ({name, label, value, tooltip: `original ${name}`})); @@ -536,11 +539,12 @@ test('main browser entry registers both views and preserves node hooks', async t const media = new MediaNode(); media.onNodeCreated(); const panel = media.dom.find(row => row.name === 'freevideo_media').element; const [libraries, toolbar, note, connections] = panel.children; - const [castRow, sceneRow, propRow] = libraries.children; + const [castRow, sceneRow, propRow, unreadable] = libraries.children; assert.equal(libraries.className, 'fv-cast-libraries'); assert.equal(castRow, casts[0].element); assert.equal(sceneRow, casts[1].element); assert.equal(propRow, casts[2].element); + assert.equal(unreadable, casts[0].notice, 'One could-not-read line follows all three rows'); assert.equal(casts[1].options.kind, 'setting'); assert.equal(casts[2].options.kind, 'prop'); assert.equal(casts[0].options.api, api); From 88c4f75299575e36b45025b9b18334a339de78a6 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 13 Nov 2026 08:16:04 +0000 Subject: [PATCH 43/43] Release 0.5.3: update notes --- freevideo_engine/release_notes.json | 91 ++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index cbaf1005..304cb3ed 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,78 @@ { "schema": 1, - "product_version": "0.5.2", + "product_version": "0.5.3", "en": { - "summary": "The editor shows its preview and timeline again, and Mac installs no longer stop at the GPU check. Images show each step while they generate, have a Seed field and clearer failure cards, and narrow windows fit better.", + "summary": "Short films are finished for you: the built-in director picks and joins the takes, the film arrives in Autopilot as a card, and an editing project with one part per shot is ready. H3 Image takes up to 16 reference pictures, and consecutive images start sooner. Imported clips can be removed from the editor, and waits, failures, setup and narrow windows read more clearly.", "changes": [ - "Editor: the edit page keeps its layout again, so the preview and the timeline are no longer pushed off the screen by the media library.", - "Mac install: an installation or repair no longer stops at the GPU check step when every check has passed.", - "Install and update: a first installation offers H3 Video or H3 Image, and an image-only installation adds the video model later. Graphics package downloads keep trying through a slow connection, a model download that cannot resume starts again, an update restores a missing tool, and setup waits briefly for another running installation and names it.", - "Images view: the picture is shown after each step while it generates, and progress follows time with one percentage and the remaining time. A Seed field under Advanced keeps a number, or draws a new seed for each picture when empty.", - "Picture failures: the reason and Retry sit inside the card, Autopilot cards open technical details as video cards do, and memory reasons use formal wording. The report button reads \"Download report\" on the image page, in Autopilot and in the editor.", - "Edit page: the quality setting sits beside Generate and shows the number of steps. Music on the second sound track starts from its own description box.", - "Output: an Output setting can write a separate 10-bit copy of each generated video; the regular file and the page preview are unchanged.", - "Narrow windows: the node view top bar steps entries aside and then wraps, the view switch sits in the same place in every view, and Escape in the phone panel closes only the panel. At 400 px the image model name and ratio menu show whole, image editor tools wrap, the result menu opens upward when needed, and editor track names show in full.", - "Autopilot: Enter, Ctrl+Enter and paste work in the chat and image pages, the message hint fits one line on phones, the header keeps a readable job title, and long titles stay inside the job card.", - "Progress: long quiet stages of a video run show real counts, such as frames, layers or files, after the stage text.", - "Generation: the text encoder loads faster from slow drives when memory is short, and a loaded engine is reused when only the tiling changes. A library character's voice comes into the shot with its pictures, and prompt enhancement reads the picture or video of a connected load node.", - "Tutorial and messages: the tutorial adds chapters on making pictures, middle frames and saving a frame. A refusal caused by Reserved VRAM names that setting, the connection card names the localhost address to open, and a long-video join failure says whether the seam record is missing or unreadable." + "Short films: a screenplay can carry its outline and the blocking of each scene, and the blocking goes into every shot of that scene. With the built-in director, takes are chosen and joined automatically once they are shot, and the work continues after a pause, a resume or a restart. The finished film appears in the Autopilot conversation as a card that plays, opens or goes straight to editing, and it also opens as an editing project with one part per shot.", + "Reference pictures: H3 Image accepts up to 16 reference pictures and Qwen-Image 2.1 accepts four. A note appears when too many pictures are added at once, pictures that cannot fit are refused before the run starts, and Generate points to the pictures to remove when the selected model's limit is exceeded.", + "Image speed: consecutive images keep the models loaded when the graphics card and memory allow, and the same prompt is not encoded again, so the second image onward starts sooner. Results are saved as high-quality JPEG, and temporary uploads are removed after each run.", + "Editor: the Imported tab has a remove button on every clip, including ones that could not be opened. Only FreeVideo's copy is deleted, never the original file, and a clip that an edit uses, even in an earlier version, asks for a second confirmation. Work thumbnails in the media library display correctly, and the Edit button on finished-video cards is visible again.", + "Waits: prompt rewriting counts from the press, the size range shows Estimating while it is worked out, and starting a generation or opening the page from the launcher shows elapsed seconds after 3 s. Automatic subtitles show Unpacking, Autopilot shows Sending and the seconds spent waiting for a reply, and preparing reference media shows which file is in progress.", + "Failures and settings: a missing or damaged model file is explained on the failure card, and cards that suggest a repair have an Open settings button that goes to Detect & review. Offline installations are told to import the offline package again. A damaged reserved memory setting no longer blocks the settings panel or generation, and a refused save is explained under Save. The launcher report form names the real failure.", + "Install and repair: Check and repair list and download again any model file that is missing or damaged, and importing an offline package again completes files left short by an interrupted copy. An installation with a missing model folder no longer stops at the GPU check, and an engine source folder that cannot be read is built again instead of failing every retry. The setup plan shows small sizes in MB or KB, and unavailable buttons share one gray look.", + "Windows and downloads: when Windows application control blocks a component file, the GPU check and generation continue, and the block is recognised in any system language. Cards of 16 GB and more keep copying the next layer while the current one computes when commit headroom is limited. The runtime download keeps retrying the official source when a mirror refuses access, with one more public source added.", + "Narrow windows: the Autopilot conversation scrolls on its own with the header and input in view, and an unsent message shows its reason on its own line. The studio shows the queued count and each run's size and length in full, download size and speed stay together, export choices and the reserved memory title stay on one line, and setup lines wrap between phrases. The Connect an agent card keeps its arrow with its words, and a failed lookup shows only Retry and Report a problem.", + "Nodes view: switching to it no longer leaves a node under the view bar, and the voice reference, dialogue tail and empty prompt switches show Chinese names in the Chinese interface. The media panel says once, not three times, when some saved items cannot be read.", + "Generation: a queue read that receives a web page instead of data is retried like a lost connection, so repeating generation keeps going. A second-pass memory failure that a smaller retry cannot help is reported sooner.", + "Tutorial: new chapters on beat snapping, run status, generating with reference pictures, the outline and staging of a short film, and the editing project a finished film leaves." ] }, "zh": { - "summary": "编辑页重新显示预览和时间线,Mac 安装不再停在 GPU 检查一步;图片生成时逐步显示画面,新增种子输入,失败卡片更清楚,窄窗口排版更合适。", + "summary": "短剧可以一路做完:内置导演自动选片并接成影片,成片以卡片出现在 AI 全自动对话中,同时建好按镜头分段的剪辑工程。H3 Image 支持最多 16 张参考图,连续出图起步更快。剪辑器可以删除已导入素材,等待、失败、安装和窄窗口的显示更清楚。", "changes": [ - "编辑页:版面恢复正常,预览和时间线不再被素材库挤出屏幕。", - "Mac 安装:安装或修复时,各项检查都已通过却仍停在 GPU 检查一步的问题已修复。", - "安装和更新:首次安装可选 H3 Video 或 H3 Image,只装图像模型的以后可再加视频模型。GPU 组件下载在网络慢时持续重试,无法续传的模型下载重新开始,更新补回缺失的工具,另一个安装进行中时稍等并说明是哪一个。", - "图像视图:生成时每一步都显示当前画面,进度按时间推进,只显示一个百分比和剩余时间。“高级”里新增“种子”,填数字固定种子,留空则每张图重新取种子。", - "图片失败:原因和“重试”放在卡片内,AI 全自动中的卡片和视频卡片一样可展开技术详情,内存不足的原因改为书面措辞。图像页、AI 全自动和编辑页的报告按钮统一为“下载报告”。", - "编辑页:画质设置放在“生成”旁边,并显示步数。第二条声音轨上的音乐从自己的描述框开始填写。", - "输出:可在设置中另外写一份 10 位视频,原有文件和页面预览不变。", - "窄窗口:节点视图顶栏的入口先依次让位再折行,各视图的切换位置一致,手机面板里按 Esc 只关闭面板。400 宽下图像模型名称和比例菜单完整显示,图像编辑工具折行,结果菜单放不下时向上展开,剪辑轨道名称完整显示。", - "AI 全自动:对话页和图像页的 Enter、Ctrl+Enter 和粘贴恢复正常,手机上的输入提示一行放下,顶栏保留可读的任务名,长标题不再超出任务卡片。", - "进度:视频生成中较长的无变化阶段,在阶段文字后显示真实计数,例如帧数、层数或文件数。", - "生成:内存紧张时,文本编码器从慢速硬盘加载更快;只有分块变化时复用已加载的引擎。素材库角色的声音随其图片一起带入镜头,提示词增强会读取所连加载节点中的图片或视频。", - "教程和提示:教程新增生成图片、中间帧和保存帧三章。只因“预留显存”导致的拒绝会指出该设置,连接卡片会给出应打开的 localhost 地址,长视频拼接失败时说明接缝记录是缺失还是无法读取。" + "短剧:剧本可以写大纲和每场站位,站位自动写进该场每个镜头。选用内置导演时,拍完后自动选片并接成影片,暂停、继续和重启后都会接着做。成片以卡片出现在 AI 全自动对话中,可以播放、打开或直接进入剪辑,同时建好按镜头分段的剪辑工程。", + "参考图:H3 Image 最多可用 16 张参考图,Qwen-Image 2.1 可用 4 张。一次加入过多时显示提示,放不下的参考图在开始前拒绝;超出所选模型上限时,点“生成”会定位到需要移除的图。", + "出图速度:显存和内存允许时,连续生成图片保持模型常驻,同一提示词不再重复编码,第二张起开始得更快。结果保存为高质量 JPEG,每次生成后清理临时上传。", + "剪辑器:“已导入”页的每个素材(包括打不开的)都有删除按钮,只删除软件复制的副本,不删原文件;被剪辑使用的素材(包括只在早先版本中使用的)需再确认一次。素材库中的作品缩略图正常显示,已完成视频卡片上的“剪辑”按钮重新显示。", + "等待:提示词改写从点下就计秒,可生成范围计算时显示“正在估算…”,开始生成和从启动器打开页面超过 3 秒时显示已用秒数。自动字幕解压时显示“正在解压”,AI 全自动显示“正在发送…”和等待回复的秒数,准备参考素材时显示正在处理第几个文件。", + "失败和设置:缺失或损坏的模型文件在失败卡片上说明原因;建议修复的卡片新增“打开设置”,直接定位到“检测并预览”。离线安装改为提示重新导入离线包。预留显存设置文件损坏时,设置面板和生成照常进行;保存被拒时在“保存”下说明原因。启动器的报告表单写出真实的失败原因。", + "安装和修复:检测和修复会列出并重新下载缺失或损坏的模型文件,重新导入离线包会补全复制中断留下的不完整文件。缺少模型文件夹时,安装不再停在 GPU 检查一步;引擎源码目录无法读取时改为重新构建,不再每次重试都失败。安装计划中较小的文件以 MB 或 KB 显示,不可用的按钮统一为灰色。", + "Windows 和下载:Windows 应用控制拦截某个组件文件时,GPU 检查和生成照常继续,在任何系统语言下都能识别该拦截。提交余量受限时,16 GB 及以上显卡在计算当前层的同时仍会拷贝下一层。运行环境下载在镜像拒绝访问时继续重试官方线路,并新增一个公开备用线路。", + "窄窗口:AI 全自动的对话单独滚动,顶栏和输入框始终可见,没发出的消息原因单独显示一行。创作面板完整显示排队数量以及每条的尺寸和时长,下载进度的大小和速度不再拆开,导出选项和预留显存标题保持一行,安装页文字按词组换行。连接 Agent 卡片的箭头不再和文字分开,获取地址失败时只显示重试和提交问题。", + "节点视图:切换过来时节点不再被顶部的视图切换栏遮住,“声音参考”“隐藏对白尾段”“允许空描述”三个开关显示中文名称。素材区有资料读取失败时只提示一次,不再重复三次。", + "生成:队列读取得到网页而不是数据时按断线重试,重复生成不再被停掉。第二遍显存不足且缩小重试也无济于事时,更早报告失败。", + "教程:新增对齐音乐节拍、运行状态、用参考图生成图片、短剧的大纲和站位、短剧成片的剪辑工程五章。" ] }, "history": [ + { + "product_version": "0.5.2", + "en": { + "summary": "The editor shows its preview and timeline again, and Mac installs no longer stop at the GPU check. Images show each step while they generate, have a Seed field and clearer failure cards, and narrow windows fit better.", + "changes": [ + "Editor: the edit page keeps its layout again, so the preview and the timeline are no longer pushed off the screen by the media library.", + "Mac install: an installation or repair no longer stops at the GPU check step when every check has passed.", + "Install and update: a first installation offers H3 Video or H3 Image, and an image-only installation adds the video model later. Graphics package downloads keep trying through a slow connection, a model download that cannot resume starts again, an update restores a missing tool, and setup waits briefly for another running installation and names it.", + "Images view: the picture is shown after each step while it generates, and progress follows time with one percentage and the remaining time. A Seed field under Advanced keeps a number, or draws a new seed for each picture when empty.", + "Picture failures: the reason and Retry sit inside the card, Autopilot cards open technical details as video cards do, and memory reasons use formal wording. The report button reads \"Download report\" on the image page, in Autopilot and in the editor.", + "Edit page: the quality setting sits beside Generate and shows the number of steps. Music on the second sound track starts from its own description box.", + "Output: an Output setting can write a separate 10-bit copy of each generated video; the regular file and the page preview are unchanged.", + "Narrow windows: the node view top bar steps entries aside and then wraps, the view switch sits in the same place in every view, and Escape in the phone panel closes only the panel. At 400 px the image model name and ratio menu show whole, image editor tools wrap, the result menu opens upward when needed, and editor track names show in full.", + "Autopilot: Enter, Ctrl+Enter and paste work in the chat and image pages, the message hint fits one line on phones, the header keeps a readable job title, and long titles stay inside the job card.", + "Progress: long quiet stages of a video run show real counts, such as frames, layers or files, after the stage text.", + "Generation: the text encoder loads faster from slow drives when memory is short, and a loaded engine is reused when only the tiling changes. A library character's voice comes into the shot with its pictures, and prompt enhancement reads the picture or video of a connected load node.", + "Tutorial and messages: the tutorial adds chapters on making pictures, middle frames and saving a frame. A refusal caused by Reserved VRAM names that setting, the connection card names the localhost address to open, and a long-video join failure says whether the seam record is missing or unreadable." + ] + }, + "zh": { + "summary": "编辑页重新显示预览和时间线,Mac 安装不再停在 GPU 检查一步;图片生成时逐步显示画面,新增种子输入,失败卡片更清楚,窄窗口排版更合适。", + "changes": [ + "编辑页:版面恢复正常,预览和时间线不再被素材库挤出屏幕。", + "Mac 安装:安装或修复时,各项检查都已通过却仍停在 GPU 检查一步的问题已修复。", + "安装和更新:首次安装可选 H3 Video 或 H3 Image,只装图像模型的以后可再加视频模型。GPU 组件下载在网络慢时持续重试,无法续传的模型下载重新开始,更新补回缺失的工具,另一个安装进行中时稍等并说明是哪一个。", + "图像视图:生成时每一步都显示当前画面,进度按时间推进,只显示一个百分比和剩余时间。“高级”里新增“种子”,填数字固定种子,留空则每张图重新取种子。", + "图片失败:原因和“重试”放在卡片内,AI 全自动中的卡片和视频卡片一样可展开技术详情,内存不足的原因改为书面措辞。图像页、AI 全自动和编辑页的报告按钮统一为“下载报告”。", + "编辑页:画质设置放在“生成”旁边,并显示步数。第二条声音轨上的音乐从自己的描述框开始填写。", + "输出:可在设置中另外写一份 10 位视频,原有文件和页面预览不变。", + "窄窗口:节点视图顶栏的入口先依次让位再折行,各视图的切换位置一致,手机面板里按 Esc 只关闭面板。400 宽下图像模型名称和比例菜单完整显示,图像编辑工具折行,结果菜单放不下时向上展开,剪辑轨道名称完整显示。", + "AI 全自动:对话页和图像页的 Enter、Ctrl+Enter 和粘贴恢复正常,手机上的输入提示一行放下,顶栏保留可读的任务名,长标题不再超出任务卡片。", + "进度:视频生成中较长的无变化阶段,在阶段文字后显示真实计数,例如帧数、层数或文件数。", + "生成:内存紧张时,文本编码器从慢速硬盘加载更快;只有分块变化时复用已加载的引擎。素材库角色的声音随其图片一起带入镜头,提示词增强会读取所连加载节点中的图片或视频。", + "教程和提示:教程新增生成图片、中间帧和保存帧三章。只因“预留显存”导致的拒绝会指出该设置,连接卡片会给出应打开的 localhost 地址,长视频拼接失败时说明接缝记录是缺失还是无法读取。" + ] + } + }, { "product_version": "0.5.1", "en": {