From ac0d217b9d3bff8b46790c6232c9995cf1a94895 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 01/13] Nodes: capitalize width, height and seed labels --- freevideo_engine/comfy_nodes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index 7232ac24..2df1d2b3 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -31,12 +31,12 @@ def define_schema(cls): inputs=[ io.String.Input('text', display_name='Prompt', multiline=True, default=DEFAULT_PROMPT, tooltip='Describe the scene, motion, dialogue, sound and music.'), - io.Int.Input('width', default=1344, min=256, max=4096, step=32), - io.Int.Input('height', default=768, min=256, max=4096, step=32), + io.Int.Input('width', display_name='Width', default=1344, min=256, max=4096, step=32), + io.Int.Input('height', display_name='Height', default=768, min=256, max=4096, step=32), io.Float.Input('seconds', display_name='Duration (s)', default=10., min=1.625, max=60., step=.1, tooltip='24 fps. The length is rounded up slightly to a length the model can generate; ' 'for example, 10 s becomes 10.125 s. Videos longer than 15 s are experimental.'), - io.Int.Input('seed', default=2026090903, min=0, max=2**53-1, control_after_generate=True), + io.Int.Input('seed', display_name='Seed', default=2026090903, min=0, max=2**53-1, control_after_generate=True), io.Image.Input('first', display_name='First frame', optional=True, tooltip='One image. Center-cropped to the selected canvas; enables I2VA.'), io.Image.Input('last', display_name='Last frame', optional=True, From a3f4d405825fc6930b38884dde2ac374b30bde30 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 02/13] Honor target audio tracks when dragging clips --- web/editor_model.js | 25 ++++++++++++++++++------- web/editor_tracks.js | 39 +++++++++++++++++++++++++++------------ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/web/editor_model.js b/web/editor_model.js index 71d60985..9bb4507b 100644 --- a/web/editor_model.js +++ b/web/editor_model.js @@ -449,15 +449,18 @@ function storedSize(record) { return size; } // A saved video brings the description it was generated from; one that cannot be kept is left out. -export function insertSource(record, {source, frames, hasVideo, hasAudio, width, height, at: position = 0, text = '', by = 'user'}) { +export function insertSource(record, {source, frames, hasVideo, hasAudio, width, height, at: position = 0, text = '', by = 'user', targetTrack}) { const given = source; source = sourceData(source); check(whole(frames, 1) && whole(position), 'range'); check(typeof hasVideo === 'boolean' && typeof hasAudio === 'boolean' && (hasVideo || hasAudio), 'source'); check(TEXT_BY.includes(by), 'text'); const words = hasVideo && typeof text === 'string' && validText(text.trim()) ? text.trim() : ''; const next = copy(record); if (hasVideo) followFirstSize(next, width, height); - let track = trackBy(next, hasVideo ? 'v1' : 'a1'); - if (!hasVideo && track.clips.some(clip => overlaps(clip, position, frames))) track = audioTrack(next, 'a2'); + const explicitAudioTrack = !hasVideo && targetTrack !== undefined; + if (explicitAudioTrack) check(['a1', 'a2'].includes(targetTrack), 'track'); + let track = explicitAudioTrack ? audioTrack(next, targetTrack) : trackBy(next, hasVideo ? 'v1' : 'a1'); + if (!hasVideo && !explicitAudioTrack && track.clips.some(clip => overlaps(clip, position, frames))) track = audioTrack(next, 'a2'); + check(!explicitAudioTrack || !track.locked, 'locked'); const start = insertionAt(next, track.id, position); const available = hasVideo && hasAudio ? copy(makeRoom(next, start, frames, {tracks: [track.id]})) : null; const sound = available ? audioTrack(next, sourceAudioTrack(available, start, frames, true).id) : null; @@ -471,21 +474,29 @@ export function insertSource(record, {source, frames, hasVideo, hasAudio, width, } const result = finish(next); // The description is a convenience: a project it would make too large to save takes the video without it. - if (words && storedSize(result) > STORED_ROOM) return insertSource(record, {source: given, frames, hasVideo, hasAudio, width, height, at: position}); + if (words && storedSize(result) > STORED_ROOM) return insertSource(record, {source: given, frames, hasVideo, hasAudio, width, height, at: position, targetTrack}); return result; } -export function move(record, id, start) { +export function move(record, id, start, {targetTrack} = {}) { if (isText(find(record, id))) return changeText(record, id, {start}); check(whole(start), 'range'); const selected = pair(record, id); check(selected.length, 'missing'); busy(selected); const next = copy(record), ids = new Set(selected.map(clip => clip.id)); - const clips = all(next).filter(clip => ids.has(clip.id)), primary = clips[0], track = trackOf(next, primary.id); + const clips = all(next).filter(clip => ids.has(clip.id)), primary = clips[0]; + let track = trackOf(next, primary.id); + // Only independent audio changes rows; linked pairs keep automatic routing. + if (clips.length === 1 && track.kind === 'audio' && targetTrack !== undefined) { + check(['a1', 'a2'].includes(targetTrack), 'track'); + assertUnlocked(record, [primary.id]); + track = audioTrack(next, targetTrack); + check(!track.locked, 'locked'); + } for (const row of next.tracks) row.clips = row.clips.filter(clip => !ids.has(clip.id)); start = insertionAt(next, track.id, start); const sound = track.kind === 'video' && clips.find(clip => trackOf(record, clip.id).kind === 'audio'); const available = sound ? makeRoom(next, start, primary.frames, {tracks: [track.id]}) : null; const soundTrack = sound ? sourceAudioTrack(available, start, primary.frames, true).id : null; applyRoom(next, start, primary.frames, {tracks: [track.id, ...(soundTrack ? [soundTrack] : [])], excluded: ids}); - for (const clip of clips) { clip.start = start; (clip === sound ? audioTrack(next, soundTrack) : trackBy(next, trackOf(record, clip.id).id)).clips.push(clip); } + for (const clip of clips) { clip.start = start; (clip === sound ? audioTrack(next, soundTrack) : clip === primary ? track : trackBy(next, trackOf(record, clip.id).id)).clips.push(clip); } return finish(next); } // A selected group keeps its gaps and tracks; validation rejects occupied landings. diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 2ab5f969..c1d8ef45 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -210,6 +210,15 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const pixels = frame => frameToPixels(frame, state.zoom); const frameAt = event => Math.max(0, pixelsToFrame(event.clientX - content.getBoundingClientRect().left, state.zoom)); const trackAt = event => event.target.closest?.('[data-track]')?.dataset.track || 'v1'; + // Pointer capture retargets events to the view; locate audio rows by geometry. + const pointerTrackAt = event => { + const y = event.clientY - content.getBoundingClientRect().top; + return [...rows].find(([, row]) => y >= row.top && y < row.top + row.height)?.[0]; + }; + const audioTargetAt = (source, event) => { + const track = pointerTrackAt(event); + return !source.hasVideo && source.hasAudio && ['a1', 'a2'].includes(track) ? track : undefined; + }; const sourceInfo = key => typeof sources === 'function' ? sources(key) : sources.get?.(key) || sources[key]; // The snapping switch lives in the transport model: it empties the point list (and Alt flips it per gesture). const snap = snapFrame; @@ -589,7 +598,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = tooltip.hidden = true; plannedLines.hidden = false; cachedPlan = ''; clearTimeout(estimateTimer); estimateToken++; for (const id of [...(gesture?.ids || []), ...(gesture?.rightPreviewIds || [])]) { const item = locate(id); - if (item) { item.element.classList.remove('fv-ed-dragging'); position(item.element, item.clip.start, item.clip.frames); } + if (item) { item.element.classList.remove('fv-ed-dragging'); item.element.style.transform = ''; position(item.element, item.clip.start, item.clip.frames); } } delete element.dataset.cursor; selectionBox.classList.remove('fv-ed-dragging', 'fv-ed-cannot-drop'); plannedLines.querySelectorAll('[data-limited]').forEach(line => line.removeAttribute('data-limited')); @@ -777,8 +786,11 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const desired = gesture.clip.start + frame - gesture.anchor; const result = moveParameters(gesture.clip, gesture.anchor, frame, dragPoints(desired, gesture.clip.frames), state.zoom); result.track = isText(gesture.clip) ? pointerHit(event).track : null; + const pointedTrack = pointerTrackAt(event); + result.targetTrack = gesture.ids.length === 1 && ['a1', 'a2'].includes(gesture.track) + && ['a1', 'a2'].includes(pointedTrack) ? pointedTrack : undefined; gesture.result = result; - const track = state.record.tracks.find(row => row.id === gesture.track); + const track = state.record.tracks.find(row => row.id === (result.targetTrack || gesture.track)); const drop = isText(gesture.clip) ? {hit: false} : insertionPoint(track.clips, result.frame, gesture.ids); showInsertion(drop.hit ? drop.frame : null); showGuide(result.guide); for (const id of gesture.rightPreviewIds || []) { @@ -788,7 +800,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = let preview, error, shifted = 0; try { if (textDropLocked(state.record, gesture.members, result.track)) throw new model.EditError('locked'); - preview = model.move(state.record, gesture.id, result.frame); + preview = model.move(state.record, gesture.id, result.frame, {targetTrack: result.targetTrack}); } catch (caught) { if (!(caught instanceof model.EditError)) throw caught; error = caught; } if (preview) for (const row of preview.tracks) for (const clip of row.clips) { @@ -800,10 +812,13 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = } const landing = preview ? model.find(preview, gesture.id).start : result.frame; element.dataset.cursor = error ? 'not-allowed' : 'grabbing'; + const previewTracks = new Map(result.targetTrack ? preview?.tracks.flatMap(row => row.clips.map(clip => [clip.id, row.id])) : []); for (const id of gesture.ids) { const item = locate(id); item.element.classList.add('fv-ed-dragging'); position(item.element, landing, item.clip.frames); + const row = previewTracks.get(id) || item.track; + item.element.style.transform = row === item.track ? '' : `translateY(${rows.get(row).top - rows.get(item.track).top}px)`; } - box(selectionBox, {start: landing, frames: gesture.clip.frames, tracks: gesture.ids.map(id => locate(id).track)}); + box(selectionBox, {start: landing, frames: gesture.clip.frames, tracks: gesture.ids.map(id => previewTracks.get(id) || locate(id).track)}); hint({start: landing, frames: gesture.clip.frames}, event, false, true); selectionBox.classList.toggle('fv-ed-cannot-drop', !!error); if (error) tooltip.hidden = true; @@ -921,7 +936,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = if (!result) return; if (finished.type === 'move') attempt(t('Move', '移动'), record => { if (textDropLocked(record, finished.members, result.track)) throw new model.EditError('locked'); - return model.move(record, finished.id, result.frame); + return model.move(record, finished.id, result.frame, {targetTrack: result.targetTrack}); }); else if (finished.type === 'plannedBoundary') attempt(t('Resize parts', '调整段长'), record => model.resizePlannedBoundary(record, finished.id, finished.neighbor.clip.id, result.frame, actions.generationLimits?.())); else if (result.kind === 'planned') attempt(t('Resize', '改长短'), record => model.resizePlanned(record, finished.id, finished.edge, result.frame, actions.generationLimits?.())); @@ -1051,12 +1066,12 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = if (!Array.from(event.dataTransfer?.types || []).includes(SOURCE_TYPE)) return; event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'copy'; const frame = snap(frameAt(event), transportSnapPoints(state, allPoints([]), event), state.zoom); - const key = `${frame.frame}/${state.zoom}/${draggedSource ? JSON.stringify(draggedSource) : trackAt(event)}`; + const key = `${frame.frame}/${state.zoom}/${pointerTrackAt(event)}/${draggedSource ? JSON.stringify(draggedSource) : ''}`; if (key !== dropPreviewKey || sourcePreview?.record !== state.record) { clearSourcePreview(); dropPreviewKey = key; if (draggedSource) try { - const preview = model.insertSource(state.record, {...draggedSource, at: frame.frame}); + const preview = model.insertSource(state.record, {...draggedSource, at: frame.frame, targetTrack: audioTargetAt(draggedSource, event)}); const added = previewSourcePlacement(preview), sound = added.find(item => item.track !== 'v1'); const name = sound ? preview.tracks.length === 3 ? t(`Audio ${sound.track === 'a1' ? 1 : 2}`, `声音 ${sound.track === 'a1' ? 1 : 2}`) : t('Audio', '声音') : ''; @@ -1067,7 +1082,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = dropPoint = {frame: frame.frame, error, label: null}; } else { - const track = state.record.tracks.find(row => row.id === trackAt(event)); + const track = state.record.tracks.find(row => row.id === pointerTrackAt(event)); dropPoint = insertionPoint(track?.clips || [], frame.frame); } } @@ -1093,11 +1108,11 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const payload = sourcePayload(event.dataTransfer?.getData(SOURCE_TYPE)); if (!payload) { showInsertion(null); showGuide(null); return; } event.preventDefault(); event.stopPropagation(); - // Use the pointer position; the model chooses the audio track and shifts - // following clips. The insertion line is an indication, not an edit. + // Audio-only library drops honor the pointed row through normal insertion rules. const at = snap(frameAt(event), transportSnapPoints(state, allPoints([]), event), state.zoom).frame; - if (actions.insertSource) Promise.resolve(actions.insertSource({...payload, at}, {loadMetadata: true})).catch(error => actions.editError?.(error)); - else attempt(t('Insert clip', '放入素材'), record => model.insertSource(record, {...payload, at}), {clearRange: true}); + const targetTrack = audioTargetAt(payload, event); + if (actions.insertSource) Promise.resolve(actions.insertSource({...payload, at, targetTrack}, {loadMetadata: true})).catch(error => actions.editError?.(error)); + else attempt(t('Insert clip', '放入素材'), record => model.insertSource(record, {...payload, at, targetTrack}), {clearRange: true}); showInsertion(null); showGuide(null); dropPoint = null; dropPreviewKey = ''; draggedSource = null; } function zoom(value, anchor = scroll.clientWidth / 2) { From 93dafc4f018bbac97ffb7acbc9d01a82d23ef447 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 03/13] Editor: say so when a split, trim or full screen is refused --- web/editor_select.js | 1 + web/editor_transport.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/editor_select.js b/web/editor_select.js index 684ebbb6..764d0a25 100644 --- a/web/editor_select.js +++ b/web/editor_select.js @@ -85,6 +85,7 @@ export function createSelectionActions({state, t = en => en, actions = {}, clipb const ids = selectedIds(state.record, state.selection); if (key === 'a') { state.select(selection(unlockedIds(state.record))); return {ok: true}; } if (['b', 'q', 'w'].includes(key) && !playheadIds(state.record, state.selection, state.playhead).length) { + actions.notify?.(t('Select a clip and move the playhead onto it.', '先选中片段,再把播放头移到片段上。')); return {ok: false, reason: 'playhead'}; } if (key === 'b') { state.apply(t('Split', '剪开'), record => actionsModel.splitAt(record, ids, state.playhead)); return {ok: true}; } diff --git a/web/editor_transport.js b/web/editor_transport.js index a3c5ee84..85b1c787 100644 --- a/web/editor_transport.js +++ b/web/editor_transport.js @@ -183,7 +183,7 @@ export function createEditorTransport({element, preview, transport, toolbar, edi try { if (document.fullscreenElement === fullHost) await document.exitFullscreen(); else { sheet.hidden = true; await fullHost.requestFullscreen(); } - } catch { return {ok: false, reason: 'fullscreen'}; } + } catch { notify?.(t('Full screen is not available here.', '这里不能全屏。')); return {ok: false, reason: 'fullscreen'}; } refresh(); } const handlers = { From 6935bb6651cc9dac9a508151337d3bdd2f92e57a Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 04/13] Record memory and exit class when a generation worker ends unexpectedly The image and video diagnostics now take system memory (physical, commit headroom) and this process's working set / commit at request start and when the worker exit is seen. The exit code is kept as given with a fixed-word class and a suspected-system-kill flag; our own stops are never flagged. --- freevideo_engine/adaptive.py | 7 ++ freevideo_engine/diagnostic_resources.py | 5 + freevideo_engine/diagnostic_summary.py | 8 +- freevideo_engine/exit_diagnostics.py | 127 +++++++++++++++++++++++ freevideo_engine/generate.py | 4 + freevideo_engine/image_gen/failure.py | 19 +++- freevideo_engine/image_gen/service.py | 31 ++++-- 7 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 freevideo_engine/exit_diagnostics.py diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index ea166ea2..102a6ad6 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -101,6 +101,13 @@ def classify_failure(error, metrics=None): result['ram_guard'] = {key: telemetry['ram_guard'].get(key) for key in ('reasons', 'working_bytes', 'budget_bytes', 'available_bytes', 'emergency_floor_bytes', 'physical_available_bytes', 'commit_available_bytes') if key in telemetry['ram_guard']} + if isinstance(error, WorkerExit) and isinstance(telemetry, dict): + # The worker is gone; these are the controller's readings at its start and at its exit. + from .exit_diagnostics import clean_reading, clean_sample + if isinstance(telemetry.get('worker_exit'), dict): + result['worker_exit'] = clean_reading(telemetry['worker_exit']) + if isinstance(telemetry.get('memory_at_start'), dict): + result['memory_at_start'] = clean_sample(telemetry['memory_at_start']) if cleanup: result['cleanup_evidence'] = [str(item) for item in cleanup] if getattr(error, 'worker_stop_confirmed', None) is False: diff --git a/freevideo_engine/diagnostic_resources.py b/freevideo_engine/diagnostic_resources.py index 495137c7..cf4ed692 100644 --- a/freevideo_engine/diagnostic_resources.py +++ b/freevideo_engine/diagnostic_resources.py @@ -681,6 +681,11 @@ def failure(value): if isinstance(value.get('memory_collection_error'), list): result['memory_collection_error'] = [trace(row) for row in value['memory_collection_error'][:4]] result['allocation'] = numbers(value.get('allocation'), ('fp8_ff_activation_stash_bytes',)) + from .exit_diagnostics import clean_reading, clean_sample + if isinstance(value.get('worker_exit'), dict): + result['worker_exit'] = clean_reading(value['worker_exit']) + if isinstance(value.get('memory_at_start'), dict): + result['memory_at_start'] = clean_sample(value['memory_at_start']) if value.get('kind') in ('gpu_oom', 'ram_pressure', 'cuda_error', 'timeout', 'cancelled', 'code_error', 'unknown_worker_exit', 'worker_stop_unconfirmed'): result['kind'] = value['kind'] diff --git a/freevideo_engine/diagnostic_summary.py b/freevideo_engine/diagnostic_summary.py index 04054806..dd9b2327 100644 --- a/freevideo_engine/diagnostic_summary.py +++ b/freevideo_engine/diagnostic_summary.py @@ -151,8 +151,12 @@ def summary_report(report): for row in failures: kind = row.get('kind') if kind in ('gpu_oom','ram_pressure','cuda_error','timeout','cancelled','code_error','unknown_worker_exit'): - result['errors'].append(dict(kind=kind, ram_guard=_measurements(row.get('ram_guard'), - ('working_bytes','budget_bytes','available_bytes','emergency_floor_bytes')))) + error = dict(kind=kind, ram_guard=_measurements(row.get('ram_guard'), + ('working_bytes','budget_bytes','available_bytes','emergency_floor_bytes'))) + if isinstance(row.get('worker_exit'), dict): + from .exit_diagnostics import clean_reading + error['worker_exit'] = clean_reading(row['worker_exit']) + result['errors'].append(error) # Frame names from our own source help locate failures without retaining # arbitrary exception messages (which may contain a prompt or access key). known = {p.name for p in Path(__file__).parent.glob('*.py')} diff --git a/freevideo_engine/exit_diagnostics.py b/freevideo_engine/exit_diagnostics.py new file mode 100644 index 00000000..e51349c2 --- /dev/null +++ b/freevideo_engine/exit_diagnostics.py @@ -0,0 +1,127 @@ +"""Memory readings and a fixed-word reading of how a generation worker ended. + +A worker that the system ends cannot report its own memory, so the process that +started it takes the readings: once when the request starts and once when the +exit is seen. Only numbers and fixed words leave this module, never text from +the worker, so a diagnostic report stays free of user data. +""" +from .system import windows + +# Exit codes of a Windows process that the system, not the program, ended. +_SYSTEM_STATUS = { + 0xC000012D: 'commit_limit', # STATUS_COMMITMENT_LIMIT + 0xC0000017: 'no_memory', # STATUS_NO_MEMORY + 0xC00000FD: 'stack_overflow', # STATUS_STACK_OVERFLOW +} +_CRASH_PREFIXES = (0xC0000000, 0x80000000) +_FIELDS = ('physical_available_bytes', 'commit_available_bytes', 'commit_limit_bytes', 'total_bytes') + + +def _count(value): + return value if type(value) is int and value >= 0 else None + + +def memory_sample(pid=None): + """Free physical memory, commit headroom and the working set of one process. + + The process is this one unless a pid is given. A value the system does not + report is None; the function never raises. + """ + import os + sample = dict.fromkeys(_FIELDS) + sample.update(process_working_set_bytes=None, process_private_commit_bytes=None) + try: + from .system import system_memory + sample.update({key: _count(value) for key, value in system_memory().items() if key in _FIELDS}) + except Exception: + pass + pid = os.getpid() if pid is None else pid + try: + if windows(): + from .win32 import process_memory + row = process_memory(pid) + if row and 'rss_bytes' in row: + sample['process_working_set_bytes'] = _count(row['rss_bytes']) + sample['process_private_commit_bytes'] = _count(row['private_commit_bytes']) + else: + # Linux has no private commit figure: statm's first column is the whole address + # space, tens of GB for a CUDA process, so only the resident size is recorded. + with open('/proc/%d/statm' % pid, encoding='ascii') as stream: + resident = stream.read().split()[1] + sample['process_working_set_bytes'] = int(resident) * os.sysconf('SC_PAGE_SIZE') + except Exception: + pass + return {key: sample[key] for key in sample if sample[key] is not None} + + +def exit_reading(code, *, requested_stop=False, system=None): + """The exit code as given, a fixed-word class and whether the system probably ended it. + + requested_stop is true when this program asked the worker to stop: its own + stop is never counted as the system ending it. + """ + on_windows = windows() if system is None else system == 'Windows' + result = dict(exit_code=code if type(code) is int else None) + if type(code) is not int: + result.update(exit_class='unknown', suspected_system_kill=False) + return result + unsigned = code & 0xFFFFFFFF + if on_windows or code > 255: + result['exit_code_unsigned'] = unsigned + if code == 0: + kind, suspected = 'clean', False + elif requested_stop: + kind, suspected = 'stopped_by_us', False + elif on_windows or code > 255: + if unsigned == 0xFFFFFFFF: + # TerminateProcess(-1): our own stop ends workers with 1, so this came from outside. + kind, suspected = 'terminated_externally', True + elif unsigned in _SYSTEM_STATUS: + kind, suspected = 'system_' + _SYSTEM_STATUS[unsigned], True + elif unsigned == 0xC000013A: + kind, suspected = 'interrupted', False + elif unsigned & 0xF0000000 in _CRASH_PREFIXES: + kind, suspected = 'native_crash', False + else: + kind, suspected = 'error_exit', False + elif code < 0 or code > 128: + # Python reports a signal as -N; a shell-style status is 128 + N. + signal = -code if code < 0 else code - 128 + if signal in (9, 15): + kind, suspected = 'killed_by_signal', True + elif signal in (4, 6, 7, 8, 11): + kind, suspected = 'native_crash', False + else: + kind, suspected = 'signal', False + else: + kind, suspected = 'error_exit', False + result.update(exit_class=kind, suspected_system_kill=suspected) + return result + + +_SAMPLE_KEYS = _FIELDS + ('process_working_set_bytes', 'process_private_commit_bytes') +_CLASSES = frozenset({'clean', 'stopped_by_us', 'terminated_externally', 'interrupted', 'native_crash', + 'error_exit', 'killed_by_signal', 'signal', 'unknown'} + | {'system_' + name for name in _SYSTEM_STATUS.values()}) + + +def clean_sample(value): + value = value if isinstance(value, dict) else {} + return {key: value[key] for key in _SAMPLE_KEYS if _count(value.get(key)) is not None} + + +def clean_reading(value): + """Only the numbers and fixed words of an exit reading, for any report.""" + value = value if isinstance(value, dict) else {} + result = {} + for key in ('exit_code', 'exit_code_unsigned', 'earlier_exit_code'): + if type(value.get(key)) is int and -2**63 <= value[key] < 2**64: + result[key] = value[key] + if value.get('exit_class') in _CLASSES: + result['exit_class'] = value['exit_class'] + if type(value.get('suspected_system_kill')) is bool: + result['suspected_system_kill'] = value['suspected_system_kill'] + memory = clean_sample(value.get('memory')) + if memory: + result['memory'] = memory + return result diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index 910120cf..d4fbaa1e 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -35,6 +35,9 @@ def child(command, env, descriptor, log, *, ram_budget_bytes=None, gpu=None, on_ memory = ProcessMemory() if ram_budget_bytes is not None else None telemetry = {} monitor = None + from .exit_diagnostics import exit_reading, memory_sample + # A worker that the system ends cannot report its own memory: this process reads it. + telemetry['memory_at_start'] = memory_sample() with log.open('w', encoding='utf-8') as stream: from .resident_process import ENV, RemoteProcess, SessionRestarted module = command[command.index('-m')+1] if '-m' in command else None @@ -92,6 +95,7 @@ def child(command, env, descriptor, log, *, ram_budget_bytes=None, gpu=None, on_ pass code = process.returncode if code: + telemetry['worker_exit'] = dict(exit_reading(code), memory=memory_sample()) raise WorkerExit(code, log) except BaseException as error: pending_error = error diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index 0035919d..ff30ff5c 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -9,6 +9,7 @@ import traceback from .. import __version__ +from ..exit_diagnostics import clean_reading, clean_sample from ..failure_details import Redactor @@ -185,8 +186,14 @@ def __init__(self, action, value): self.machine = {} self.plan = None self.pack = None + self.start_memory = None self.update_request(value) + def note_start(self): + """Memory readings when the request begins; the worker's own are lost if it is ended.""" + from ..exit_diagnostics import memory_sample + self.start_memory = memory_sample() + def update_request(self, value): if not isinstance(value, dict): return @@ -273,4 +280,14 @@ def report(self, code, *, row=None, error=None, video_busy=False, worker_restart analysis=dict(version=__version__, hardware=hardware, geometry=geometry), summary=dict(request_seconds=self.timing.seconds()), request=dict(self.request), plan=plan, stages=stages, stage_at_failure=stage, error=details, pack=clean(pack, self.redactor), - service=dict(video_busy=bool(video_busy), worker_restarts=worker_restarts)) + service=self.service(video_busy, worker_restarts, error)) + + def service(self, video_busy, worker_restarts, error): + service = dict(video_busy=bool(video_busy), worker_restarts=worker_restarts) + if self.start_memory: + service['memory_at_request_start'] = clean_sample(self.start_memory) + # Numbers and fixed words only, taken by the service when it saw the worker end. + reading = clean_reading(getattr(error, 'exit_reading', None)) + if reading: + service['worker_exit'] = reading + return service diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 92ebc1b3..c3ac98ca 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -178,6 +178,7 @@ def start(self, action, value): if action not in ('generate', 'compose'): raise ValueError('invalid_request') context = Context(action, value) + context.note_start() with self.mutex: if self.closed: raise ValueError('closed') @@ -586,7 +587,11 @@ def respawn(): self.infer(session, value) except RuntimeError as second: if earlier.startswith('worker_exit (') and str(second).startswith('worker_exit'): - raise RuntimeError('%s; first try: %s' % (second, earlier[len('worker_exit '):])) from None + combined = RuntimeError('%s; first try: %s' % (second, earlier[len('worker_exit '):])) + reading = getattr(second, 'exit_reading', None) + if isinstance(reading, dict): + combined.exit_reading = dict(reading, earlier_exit_code=getattr(error, 'exit_code', None)) + raise combined from None raise finally: # Keep the parent's lease until the whole owned process tree exits. @@ -658,14 +663,26 @@ def fitted(length): return True @staticmethod - def worker_gone(session, heard): + def worker_gone(session, heard, requested_stop=False): """The error for a worker pipe that ended; it carries the exit code for the report.""" + error = Service.worker_gone_error(session, heard) + code = getattr(error, 'exit_code', None) + if code is not None: + # The worker cannot report its memory once it is gone: read this process's view now. + from ..exit_diagnostics import exit_reading, memory_sample + error.exit_reading = dict(exit_reading(code, requested_stop=requested_stop), memory=memory_sample()) + return error + + @staticmethod + def worker_gone_error(session, heard): try: code = session.process.wait(timeout=2) except subprocess.TimeoutExpired: code = None if code == 0 and not heard: - return WorkerIdleExit('worker_exit (exit code 0, before any answer)') + idle = WorkerIdleExit('worker_exit (exit code 0, before any answer)') + idle.exit_code = 0 + return idle if code is None: return RuntimeError('worker_exit') session.error_reader.join(timeout=1) @@ -676,7 +693,9 @@ def worker_gone(session, heard): message = 'worker_exit (exit code %d%s)' % (code, ', ' + kind if kind else '') # A worker that ended with an error before answering never ran the request; one # fresh worker gets it, and a second exit is reported with its own last words. - return (WorkerEarlyExit if not heard and code > 0 else RuntimeError)(message) + error = (WorkerEarlyExit if not heard and code > 0 else RuntimeError)(message) + error.exit_code = code + return error def infer(self, session, value): context = self.context or Context('generate', value) @@ -684,7 +703,7 @@ def infer(self, session, value): try: session.send(action='generate', **value) except OSError: - raise self.worker_gone(session, heard) + raise self.worker_gone(session, heard, self.stop.is_set()) cancelled_at = None deadline = time.monotonic() + INFERENCE_TIMEOUT while True: @@ -702,7 +721,7 @@ def infer(self, session, value): except queue.Empty: continue if row is None: - raise self.worker_gone(session, heard) + raise self.worker_gone(session, heard, self.stop.is_set()) heard = True phase = row.get('phase') if phase == 'progress': From 334e80dc3ddfd5902d5ba89cd715e6e644dc15ed Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 05/13] Mac: start the time estimate from the chip The starting estimate on Mac now scales the shared Apple prior by chip generation and tier. A chip without its own measurements takes the nearest older generation, and the first measured step still corrects it. --- freevideo_engine/stage_estimate.py | 38 ++++++++++++++++++--- freevideo_engine/stage_estimate_params.json | 34 +++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/freevideo_engine/stage_estimate.py b/freevideo_engine/stage_estimate.py index 752bd6dd..b2f597c6 100644 --- a/freevideo_engine/stage_estimate.py +++ b/freevideo_engine/stage_estimate.py @@ -95,6 +95,36 @@ def _apple(h): return 'Apple' in _gpu(h) or h.get('os') == 'Darwin' +APPLE_TIERS = ('base', 'Pro', 'Max', 'Ultra') + + +def _apple_factors(h, p): + """(step, decode) multipliers for the Apple chip; (1, 1) is the M4 Max fit. + + Only some chips have a report. A generation with no report takes the nearest + older generation that has one (the nearest newer one if there is none older), + so a guess errs long; the first measured step then corrects it. Inside the + generation used, a tier with no report steps down to the next lower tier, and + a tier lower than all of them is slower by a fixed ratio. + """ + table = (p.get('apple') or {}).get('chip_factors') + found = re.search(r'\bM(\d+)(?:\s+(Pro|Max|Ultra))?', _gpu(h)) + if not table or not found: + return 1., 1. + chips = {int(g): v for g, v in table['chips'].items()} + generation, tier = int(found.group(1)), found.group(2) or 'base' + if generation not in chips: + older = [g for g in chips if g < generation] + generation = max(older) if older else min(chips, key=lambda g: abs(g-generation)) + own = chips[generation] + rank = APPLE_TIERS.index(tier) + for r in range(rank, -1, -1): + if APPLE_TIERS[r] in own: + return tuple(own[APPLE_TIERS[r]]) + lowest = own[min(own, key=APPLE_TIERS.index)] + return tuple(x*float(table['lower_tier_slowdown']) for x in lowest) + + def speed(h, p, spec_only=False): key = _gpu(h) if _apple(h): @@ -199,7 +229,7 @@ def step_parts(h, r, i, p): x += _reference_rows(h, r, x)/TOKEN_UNIT if _apple(h): b, c = p['apple']['step_bc'] - return p['apple']['step_fixed'], x*(b+c*x) + return p['apple']['step_fixed'], x*(b+c*x)*_apple_factors(h, p)[0] b, c = p['step']['bc_normalized'] s = _dot(stream_features(h, r, i), p['step']['s']) return max(0., s), x*(b+c*x)/speed(h, p) @@ -282,7 +312,7 @@ def _raw_base(h, r, flags, p, factor=1., mode='compute', factor_pass=1): for name in ['video_decode', 'decode']: if _apple(h): a, d = p['apple'][name] - value = a+m*d*factor + value = a+m*d*factor*_apple_factors(h, p)[1] else: a, d, stream = p[name]['coef'] ram = p[name]['ram16_multiplier'] if ram_tier(h)==0 else 1. @@ -367,7 +397,7 @@ def _decode_value(h,r,p,name,method,factor=1.,slowdown=1.,warm2=None,live=False) if _apple(h): d=canvases(r)[2]; m=d['width']*d['height']*d['frames']/1e6 a,b=p['apple'][name] - return a+m*b*factor + return a+m*b*factor*_apple_factors(h,p)[1] spec = p['v2']['decode'][name] if method in ['log_prior','log_live']: use_live = method=='log_live' and live @@ -433,7 +463,7 @@ def _interval(name,value,p,kind='prior',wide=False): def _warnings(h,r,p): out=[] - if _apple(h):out.append('Apple backend has one physical-machine report; external prior accuracy is unvalidated.') + if _apple(h):out.append('Apple prior rests on a few physical-machine reports; chips without one borrow the nearest generation.') if _gpu_item(h,p) is None:out.append('Unseen GPU: supplied dense tensor rate or G=1 fallback; wide uncertainty.') if not (r.get('sampling_geometry') or {}).get('first'):out.append('Sampling geometry inferred from current H3 engine rules.') if any(r.get(f'pass{i}_resident_blocks') is None for i in [1,2]):out.append('Missing planned transformer residency: coarse VRAM fallback.') diff --git a/freevideo_engine/stage_estimate_params.json b/freevideo_engine/stage_estimate_params.json index 69fe9f5f..6608f584 100644 --- a/freevideo_engine/stage_estimate_params.json +++ b/freevideo_engine/stage_estimate_params.json @@ -632,7 +632,39 @@ 0.0, 1.9429229278016131 ], - "training_requests": 1 + "training_requests": 5, + "chip_factors": { + "note": "[step, decode] time multipliers on the shared Apple prior (fitted on M4 Max); measured from Mac reports: M1 Max, M4 Pro, M4 Max, M5, M5 Pro. Chips without a report borrow, see _apple_factors.", + "lower_tier_slowdown": 1.4, + "chips": { + "1": { + "Max": [ + 3.2, + 1.5 + ] + }, + "4": { + "Pro": [ + 1.7, + 2.1 + ], + "Max": [ + 1.0, + 1.0 + ] + }, + "5": { + "base": [ + 0.7, + 1.0 + ], + "Pro": [ + 0.5, + 0.6 + ] + } + } + } }, "uncertainty": { "prior": { From 8f0da13321e4da1f770463d6c47111d5cab103d4 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 06/13] Estimate: leave preview runs out of the slider history so the estimate no longer fails --- freevideo_engine/effort_forecast.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/effort_forecast.py b/freevideo_engine/effort_forecast.py index 159734bb..22dc747a 100644 --- a/freevideo_engine/effort_forecast.py +++ b/freevideo_engine/effort_forecast.py @@ -87,7 +87,10 @@ def timing_record(report, gpu_uuid, system, *, stage_details=True): # Rows without the key are plain runs, as every run before the caches was. record['step_cache'] = cache if not stage_details: - return record + # The slider history carries no flow marker, and a preview (one pass) + # is never a whole-request sample: estimate() would read its missing + # second pass. Leave it out here; the detailed records mark it. + return None if preview else record upscale = video.get('latent_upscale') or {} upscale_seconds = (sum(upscale[k] for k in ('load_seconds', 'compute_seconds')) if all(type(upscale.get(k)) in (int, float) and math.isfinite(upscale[k]) From 0de640a8ae8ed7cbb776a5b3c9e6e82dc3b87c37 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 07/13] Mac: step cache (audio-video EasyCache) on the first pass; high is 20 steps with it --- freevideo_engine/agent/guide.py | 4 ++-- freevideo_engine/agent/schema.py | 2 +- freevideo_engine/capabilities.py | 4 +++- freevideo_engine/comfy_bridge.py | 6 +++--- freevideo_engine/macos_generate.py | 2 ++ freevideo_engine/macos_runtime.py | 28 +++++++++++++++++++++------- freevideo_engine/macos_stages.py | 3 ++- freevideo_engine/tiers.py | 21 +++++++++++++-------- web/sampling_effort.js | 11 +++++++---- web/share.js | 6 +++--- 10 files changed, 57 insertions(+), 30 deletions(-) diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 6a6a0681..ef9efb45 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -73,8 +73,8 @@ 'quality': [ 'Steps per quality: "light" = 8 first-pass steps + 3 refine steps, two-pass (default); "medium" = 8 steps, ' '"high" = 20 steps with a step cache, "max" = 20 steps with a step cache that skips less work, each in one pass. ' - '"max" runs plain 20 steps when the GPU has no room for its cache. On a Mac, "high" is 16 steps and ' - '"max" 20 steps, without a cache.', + '"max" runs plain 20 steps when the GPU has no room for its cache. On a Mac, "high" is the same and ' + '"max" is 20 plain steps.', 'Two-pass generation runs only with 8 first-pass steps. Any other base_steps (for example 20 with ' 'two_pass=true) runs in one pass of that many steps; the job notice says so. A preview is always two-pass ' 'with 8 steps.', diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index bad26eda..12ab2bc0 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -14,7 +14,7 @@ S = 'https://json-schema.org/draft/2020-12/schema' PREFIX = '/freevideo/agent/v1' # (two_pass, base_steps, refine_steps) of each level on this platform; the step -# cache a level adds is tiers.step_cache(name, mac) (none on Mac). +# cache a level adds is tiers.step_cache(name, mac) (on Mac only high has one, easycache-av). QUALITY = {name: tiers.plan(name, mac=tiers.on_mac()) for name in tiers.TIERS} ASPECTS = {'16:9': (16, 9), '9:16': (9, 16), '1:1': (1, 1), '4:3': (4, 3), '3:4': (3, 4), '21:9': (21, 9), '2:3': (2, 3), '3:2': (3, 2)} diff --git a/freevideo_engine/capabilities.py b/freevideo_engine/capabilities.py index bf9ea16b..817fad6c 100644 --- a/freevideo_engine/capabilities.py +++ b/freevideo_engine/capabilities.py @@ -245,7 +245,7 @@ def _admit(machine, width, height, frames, steps, two_pass, task='t2va', refine= references=None, reference_rows=None, step_cache=None): """Admission of one request; `step_cache` asks the planner for a first-pass cache. - The request carries the cache the planner keeps (never one on Mac), so its + The request carries the cache the planner keeps (on Mac only the audio-video EasyCache), so its estimate and history follow what the engine will actually run. """ canvas, sampling, request = _request(width, height, frames, steps, two_pass, task, refine, @@ -266,6 +266,8 @@ def _admit(machine, width, height, frames, steps, two_pass, task='t2va', refine= for g in (sampling['first'], sampling['second'] or sampling['first'])] request.update(pass1_resident_blocks=plans[0]['retained_layers'], pass2_resident_blocks=plans[1]['retained_layers']) + if step_cache == 'easycache-av': + request['step_cache'] = step_cache code = 0 if all(p['retained_layers'] == 50 for p in plans) else 1 if admission['state'] == 'tight': code = 2 diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index 606784d6..4744945b 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -1233,10 +1233,10 @@ def check_ending(): from .two_pass import sampling_canvas check(continue_from, sampling_canvas(canvas)[0]) - if step_cache is not None and machine.get('device_backend') == 'mps': - # The step caches are not ported to Mac yet: the request samples every step, + if step_cache == 'fbcache' and machine.get('device_backend') == 'mps': + # Only the audio-video EasyCache is ported to Mac: the request samples every step, # and its identity is that of the same request without a cache. - logging.warning('[FreeVideo] Step caches are not available on Mac yet; sampling every step.') + logging.warning('[FreeVideo] The first-block cache is not available on Mac yet; sampling every step.') step_cache = None if continue_from is not None: validate_route(backend=machine.get('device_backend', 'cuda')) diff --git a/freevideo_engine/macos_generate.py b/freevideo_engine/macos_generate.py index 38e47e32..eedc4bee 100644 --- a/freevideo_engine/macos_generate.py +++ b/freevideo_engine/macos_generate.py @@ -295,6 +295,8 @@ def execute(value, name, module='freevideo_engine.macos_generate'): base=base, task=task, checkpoint=str(Path(args.checkpoint or checkpoint_path()).resolve()), seed=args.seed, canvas=canvas, sampling=sampling, resources=resource) + if getattr(args, 'step_cache', None) == 'easycache-av': + engine['step_cache'] = args.step_cache if output_size: engine['output_size'] = [output_size['width'], output_size['height']] if preview: diff --git a/freevideo_engine/macos_runtime.py b/freevideo_engine/macos_runtime.py index 618e5a11..3e5a15ed 100644 --- a/freevideo_engine/macos_runtime.py +++ b/freevideo_engine/macos_runtime.py @@ -128,11 +128,18 @@ def conditioning_tokens(task, canvas, conditions): class Engine: + # Engines built without __init__ (tests) sample without a cache. + step_cache = None + def __init__(self, cache, *, base, checkpoint, steps=8, budget_bytes=4 * 2**30, reserve_bytes=2 * 2**30, query_chunk=128, ff_chunk=256, projection_chunk=256, weight_decoder='cpu', budget_ceiling_bytes=None, attention_chunk=None, task='t2va', canvas=None, head_chunk=4, window_batch=1, attention_impl='torch', fast_kernels=False, - resident_bytes=0, attention_batch_bytes=1 << 29, plan_compute=False, memory_retry=False): + resident_bytes=0, attention_batch_bytes=1 << 29, plan_compute=False, memory_retry=False, + step_cache=None): + if step_cache not in (None, 'easycache-av'): + raise ValueError('MPS supports the easycache-av step cache only') + self.step_cache = step_cache attention_chunk = projection_chunk if attention_chunk is None else attention_chunk if type(attention_chunk) is not int or attention_chunk < 1: raise ValueError('MPS attention row chunk must be a positive integer') @@ -561,11 +568,18 @@ def host_guard(module, inputs): frame_w=width // 32, step_offset=progress_offset, total=progress_total if progress_total is not None else active_steps): self.residency = residency - video, audio = sampler(self.model, prompt, tags, canvas['frames'], self.steps, - seed, 'mps', step_seconds=durations, - **(dict(conditions=conditions) if conditions else {}), - **(dict(initial_latents=initial_latents, refine_steps=refine_steps, refine_schedule=refine_schedule) - if refining else {})) + from .step_cache import begin_pass, end_pass + step_cache = None + if not refining and self.step_cache: + begin_pass(self.model, active_steps, self.step_cache) + try: + video, audio = sampler(self.model, prompt, tags, canvas['frames'], self.steps, + seed, 'mps', step_seconds=durations, + **(dict(conditions=conditions) if conditions else {}), + **(dict(initial_latents=initial_latents, refine_steps=refine_steps, refine_schedule=refine_schedule) + if refining else {})) + finally: + step_cache = end_pass(self.model) self.backend.synchronize() if not bool(video.isfinite().all()) or not bool(audio.isfinite().all()): raise ValueError('MPS sampling produced nonfinite video or audio latents') @@ -588,7 +602,7 @@ def host_guard(module, inputs): max_bytes=max(self.allocator_limits)), attention_calls={name: value - previous_calls[name] for name, value in self.attention.backend_calls.items()}, - step_seconds=list(durations), + step_seconds=list(durations), step_cache=step_cache, block_seconds=progress.block_seconds, block_seconds_scope=BLOCK_SECONDS_SCOPE, conditions=progress.conditions, load_seconds=self.load_seconds, residency=self._residency_metrics(residency, previous_residency), diff --git a/freevideo_engine/macos_stages.py b/freevideo_engine/macos_stages.py index cf8dd134..d51a3e8a 100644 --- a/freevideo_engine/macos_stages.py +++ b/freevideo_engine/macos_stages.py @@ -354,7 +354,8 @@ def worker(value): resident_bytes=compute.get('resident_bytes', 0), attention_batch_bytes=compute.get('attention_batch_bytes', 1 << 29), plan_compute=bool(compute), memory_retry=retry and bool(compute), - task=value.get('task', 't2va'), canvas=value['canvas']) as engine: + task=value.get('task', 't2va'), canvas=value['canvas'], + step_cache=None if refining else value.get('step_cache')) as engine: result['load_seconds'] = engine.load_seconds tick = time.monotonic() # A preview stops after the first pass; joining would also refine. diff --git a/freevideo_engine/tiers.py b/freevideo_engine/tiers.py index af1131b2..df19dc6b 100644 --- a/freevideo_engine/tiers.py +++ b/freevideo_engine/tiers.py @@ -16,8 +16,10 @@ never run for them. `step_cache` is the engine's cache key: None, 'easycache-av' (audio-video EasyCache) or 'fbcache' (first-block cache). -The caches are not ported to Mac (MPS) yet: there high is one pass of 16 -steps and max one pass of 20, both without a cache (TIERS_MAC). +On Mac (MPS) only the audio-video EasyCache is ported: high is the same plan +as everywhere else and max is one pass of 20 steps without a cache (TIERS_MAC). +Results made before that, one pass of 16 steps without a cache, still count as +high (LEGACY_MAC_HIGH). """ import sys @@ -30,8 +32,8 @@ 'high': dict(steps=20, two_pass=False, step_cache='easycache-av'), 'max': dict(steps=20, two_pass=False, step_cache='fbcache'), } -TIERS_MAC = dict(TIERS, high=dict(steps=16, two_pass=False, step_cache=None), - max=dict(steps=20, two_pass=False, step_cache=None)) +TIERS_MAC = dict(TIERS, max=dict(steps=20, two_pass=False, step_cache=None)) +LEGACY_MAC_HIGH = dict(steps=16, two_pass=False, step_cache=None) # Sampling time of a cached pass as a share of the same pass without a cache, # for estimates that have no run with that cache yet. B200, 20 s clips @@ -69,9 +71,12 @@ def step_cache(name, mac=False): def name_for(steps, two_pass, refine_steps=REFINE_STEPS, step_cache=None, mac=False): """The level with exactly this plan, or None for custom sampling.""" - return next((name for name, tier in table(mac).items() - if tier['steps'] == steps and tier['two_pass'] == two_pass - and (not two_pass or refine_steps == REFINE_STEPS) and tier['step_cache'] == step_cache), None) + found = next((name for name, tier in table(mac).items() + if tier['steps'] == steps and tier['two_pass'] == two_pass + and (not two_pass or refine_steps == REFINE_STEPS) and tier['step_cache'] == step_cache), None) + if found is None and mac and (steps, two_pass, step_cache) == tuple(LEGACY_MAC_HIGH.values()): + return 'high' + return found def table_level(steps, two_pass, refine_steps=REFINE_STEPS, step_cache=None, mac=False): @@ -79,7 +84,7 @@ def table_level(steps, two_pass, refine_steps=REFINE_STEPS, step_cache=None, mac Tables follow the step count only. Prefer the exact level (the cache tells high from max at 20 steps), then the other platform's table (20 steps - without a cache is "max", as on Mac; 16 steps is "high"), then the first + without a cache is "max", as on Mac; 16 steps is the earlier Mac "high"), then the first level with that step count. """ found = name_for(steps, two_pass, refine_steps, step_cache, mac) or name_for( diff --git a/web/sampling_effort.js b/web/sampling_effort.js index 966b2df9..4b6281a3 100644 --- a/web/sampling_effort.js +++ b/web/sampling_effort.js @@ -8,9 +8,12 @@ export const SAMPLING_EFFORTS = frozen([ {name: 'High', zh: '精细', steps: 20, twoPass: false, stepCache: 'easycache-av', color: '#9d7bff'}, {name: 'Max', zh: '极致', steps: 20, twoPass: false, stepCache: 'fbcache', color: '#ffa94d'}, ]); -// Mac (MPS) has no step caches yet: High samples 16 steps and Max 20, both in full. -const MAC = {High: {steps: 16, stepCache: null}, Max: {stepCache: null}}; +// Mac (MPS) has the audio-video EasyCache only: High is as everywhere, Max samples 20 steps in full. +const MAC = {Max: {stepCache: null}}; export const SAMPLING_EFFORTS_MAC = frozen(SAMPLING_EFFORTS.map(tier => ({...tier, ...MAC[tier.name]}))); +// Mac results made before the cache came: High was 16 steps without one. +export const legacyMacEffort = (steps, twoPass, stepCache = null) => + steps === 16 && !twoPass && stepCacheOf(stepCache) === null ? SAMPLING_EFFORTS_MAC.find(tier => tier.name === 'High') : undefined; export const effortName = (t, tier) => t(tier.name, tier.zh); export const STEP_CACHES = Object.freeze([null, 'easycache-av', 'fbcache']); @@ -41,10 +44,10 @@ export function effortFor(steps, twoPass, refine, stepCache = null, levels = SAM // The level whose sampling table a plan needs, for naming a missing download (tiers.table_level): // the exact level of either table (the cache tells High from Max at 20 steps; 20 steps -// without a cache is Max and 16 steps High, as on Mac), else the first level with those steps. +// without a cache is Max, as on Mac; 16 steps is the earlier Mac High), else the first level with those steps. export function tableLevel(steps, twoPass, refine, stepCache = null) { return effortFor(steps, twoPass, refine, stepCache) || effortFor(steps, twoPass, refine, stepCache, SAMPLING_EFFORTS_MAC) - || SAMPLING_EFFORTS.find(tier => tier.steps === steps && tier.twoPass === twoPass && (!twoPass || refine === 3)); + || legacyMacEffort(steps, twoPass, stepCache) || SAMPLING_EFFORTS.find(tier => tier.steps === steps && tier.twoPass === twoPass && (!twoPass || refine === 3)); } export function createSamplingEffort(t, {onChange, onPreview = () => {}, levels = SAMPLING_EFFORTS}) { diff --git a/web/share.js b/web/share.js index f0aea76d..ad82a6fa 100644 --- a/web/share.js +++ b/web/share.js @@ -1,6 +1,6 @@ import {api} from '../../scripts/api.js'; import {closeDialog} from './motion.js'; -import {SAMPLING_EFFORTS_MAC, effortFor, effortName} from './sampling_effort.js'; +import {SAMPLING_EFFORTS_MAC, effortFor, effortName, legacyMacEffort} from './sampling_effort.js'; import {fontCSS as bundledFontCSS, fontLoaded, loadFont} from './fonts.js'; import {shareLayout} from './share_layout.js'; import {videoUnavailableText} from './studio_extend.js'; @@ -38,9 +38,9 @@ export function drawShareCard(canvas,frame,logo,record,t) { const ctx=canvas.getContext('2d'),w=shape.width,y=shape.rect[3],H=shape.footer,pad=44,wide=w>=1000; const p=record.sampling_plan||{},steps=p.base_steps,refine=p.enabled?p.refine_steps:0; // Results made with the earlier 8 + 2 default count as Light. - // A Mac result names its level from the Mac table (High 16 steps, Max 20 without a cache). + // A Mac result names its level from the Mac table (Max 20 without a cache; earlier High 16 steps). const plan=[steps,!!p.enabled,p.enabled&&steps===8&&refine===2?3:refine,p.step_cache??null]; - const tier=effortFor(...plan)||effortFor(...plan,SAMPLING_EFFORTS_MAC), accent=tier?.color||'#8fa3bb'; + const tier=effortFor(...plan)||effortFor(...plan,SAMPLING_EFFORTS_MAC)||legacyMacEffort(steps,!!p.enabled,p.step_cache??null), accent=tier?.color||'#8fa3bb'; ctx.fillStyle='#0b1016';ctx.fillRect(0,0,w,shape.height);ctx.clearRect(...shape.rect); const bg=ctx.createLinearGradient(0,y,0,y+H);bg.addColorStop(0,'#131b26');bg.addColorStop(1,'#0b1016'); ctx.fillStyle=bg;ctx.fillRect(0,y,w,H); From ac73ed61fe5a347f5f07630b7093e3cda1f3c8f1 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 08/13] Recognise character mentions written back to back A mention that starts right after another recognised mention, with no space between the two names, is now recognised and offered in the picker. Real email addresses are still not treated as mentions. --- web/cast_mention.js | 8 ++++++-- web/prompt_references.js | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/web/cast_mention.js b/web/cast_mention.js index c49cb549..b332747d 100644 --- a/web/cast_mention.js +++ b/web/cast_mention.js @@ -72,10 +72,14 @@ export function mentionRanges(text, people) { if (!names.length || !text.includes('@')) return []; // A name written in Latin letters does not end in the middle of a longer word ("@Su Wanda" is not "@Su Wan"). const whole = name => escape(name) + (/[A-Za-z0-9_]$/.test(name) ? '(?![A-Za-z0-9_])' : ''); - const pattern = new RegExp('(? 0 && ranges[ranges.length - 1].end === match.index; + if (!joined && match.index > 0 && /[\w.%+\-]/.test(text[match.index - 1])) continue; ranges.push({start: match.index, end: match.index + 1 + match[1].length, name: match[1]}); + } return ranges; } diff --git a/web/prompt_references.js b/web/prompt_references.js index e15dae38..dcc4f365 100644 --- a/web/prompt_references.js +++ b/web/prompt_references.js @@ -98,7 +98,10 @@ export function syncReferencePrompt(node, t = en => en, reset = false) { export function mentionAt(text, start, end = start, handles = []) { if (start < 1 || start !== end) return null; const at = text.lastIndexOf('@', start - 1); - if (at < 0 || (at > 0 && /[\w.%+\-]/.test(text[at - 1]))) return null; + if (at < 0) return null; + // An @ straight after a finished "@name" opens the list again. + const joined = handles.some(name => name && text.slice(0, at).endsWith('@' + name)); + if (at > 0 && !joined && /[\w.%+\-]/.test(text[at - 1])) return null; const query = text.slice(at + 1, start); const rest = text.slice(at + 1); // A longer name that still begins like what is typed ("苏" and "苏晚") keeps the list open. From 5de67355fec9b0c2b2cc6e3aa9384b4f1b6863c4 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 09/13] Fit running Autopilot header with container queries --- web/agent_connect.css | 22 ++++++++++++++++++++-- web/autopilot.js | 2 +- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/web/agent_connect.css b/web/agent_connect.css index f6f5d75f..d5722ec3 100644 --- a/web/agent_connect.css +++ b/web/agent_connect.css @@ -34,7 +34,25 @@ .fv-autopilot-now-stop[aria-busy=true]::after{display:none} .fv-autopilot-line{position:absolute;left:0;bottom:-1px;height:2px;border-radius:0 2px 2px 0;background:linear-gradient(90deg,var(--fv-accent),var(--fv-accent-hover));transition:width .6s var(--fv-ease),opacity .4s;pointer-events:none} .fv-autopilot-line[data-on=false]{opacity:0} -@media(max-width:1050px){.fv-autopilot-now-go>em{display:none}.fv-autopilot-now-name{max-width:120px}} -@media(max-width:820px){.fv-studio.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])){height:auto;min-height:98px;grid-template-columns:auto minmax(0,1fr);padding-block:10px}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{grid-column:2;grid-row:1}} +/* Running jobs share the right column; only the title gives up space before fields hide. */ +.fv-studio.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])){grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)} +.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{justify-self:stretch;min-width:0;justify-content:flex-end;container:fv-autopilot-actions / inline-size;gap:4px} +.fv-header:has(.fv-autopilot-now:not([hidden])) .fv-header-actions>button{flex:none} +/* Compact spacing only once the column is short of room, so wide headers keep their idle rhythm. */ +@container fv-autopilot-actions (max-width:671px){.fv-header:has(.fv-autopilot-now:not([hidden])) .fv-header-actions>button{padding-inline:5px}} +.fv-header:has(.fv-autopilot-now:not([hidden])) .fv-autopilot-now{flex:0 1 auto;min-width:0} +.fv-studio .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-autopilot-now-go{flex:0 1 auto;min-width:0;gap:5px;padding-left:8px} +.fv-header:has(.fv-autopilot-now:not([hidden])) .fv-autopilot-now-go>:not(.fv-autopilot-now-name){flex:none} +.fv-header:has(.fv-autopilot-now:not([hidden])) .fv-autopilot-now-name{flex:0 1 auto;min-width:0;white-space:nowrap} +.fv-autopilot-now-go>em{overflow:hidden} +/* Four-digit minutes; thresholds intentionally stay the same when feedback becomes an icon. */ +@container fv-autopilot-actions (max-width:561px){:lang(zh) .fv-autopilot-lamp{width:30px;height:30px;padding:0;justify-content:center}:lang(zh) .fv-autopilot-lamp>span{display:none}} +@container fv-autopilot-actions (max-width:671px){:lang(en) .fv-autopilot-lamp{width:30px;height:30px;padding:0;justify-content:center}:lang(en) .fv-autopilot-lamp>span{display:none}} +@container fv-autopilot-actions (max-width:370px){:lang(zh) .fv-autopilot-now-go>em{display:none}} +@container fv-autopilot-actions (max-width:425px){:lang(en) .fv-autopilot-now-go>em{display:none}} +@container fv-autopilot-actions (max-width:289px){:lang(zh) .fv-autopilot-now-go>span{display:none}} +@container fv-autopilot-actions (max-width:322px){:lang(en) .fv-autopilot-now-go>span{display:none}} +@media(max-width:1050px){.fv-autopilot-now-name{max-width:120px}} +@media(max-width:825px){.fv-studio.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])){height:auto;min-height:98px;grid-template-columns:auto minmax(0,1fr);padding-block:10px}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{grid-column:2;grid-row:1}} @media(max-width:480px){.fv-studio.fv-autopilot .fv-header{grid-template-columns:auto minmax(0,1fr)}.fv-autopilot-now{font-size:12.5px}.fv-autopilot-now-name{display:none}.fv-autopilot .fv-header-actions>.fv-quiet{flex:none;white-space:nowrap}.fv-studio .fv-autopilot-now-go{padding:5px 2px 5px 10px}.fv-studio .fv-autopilot-lamp{width:30px;height:30px;padding:0;justify-content:center}.fv-autopilot-lamp>span{display:none}.fv-agent-connect-body{padding:22px}} @media(prefers-reduced-motion:reduce){.fv-autopilot-line{transition:none}} diff --git a/web/autopilot.js b/web/autopilot.js index a0db9751..d64dff0b 100644 --- a/web/autopilot.js +++ b/web/autopilot.js @@ -23,7 +23,7 @@ let current = null; export function openAutopilotView(node, {studio = null} = {}) { if (current?.open) { viewChanged('autopilot', node); return current; } studio?.querySelectorAll('video,audio').forEach(media => media.pause()); - const dialog = el('dialog', 'fv-studio fv-autopilot'); dialog.setAttribute('aria-label', t('Autopilot', 'AI 全自动')); + const dialog = el('dialog', 'fv-studio fv-autopilot'); dialog.lang = cn ? 'zh' : 'en'; dialog.setAttribute('aria-label', t('Autopilot', 'AI 全自动')); const header = el('header', 'fv-header'), brand = el('div', 'fv-brand'), tools = el('div', 'fv-header-actions'); brand.append(wordmark()); const settings = el('button', 'fv-quiet', t('Settings', '设置')); settings.type = 'button'; settings.onclick = openSetup; From 154627e095b9c6aff54c107b010a760e44e418ed Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 10/13] Keep quoted button names on one line in chat errors --- web/autopilot_chat.css | 1 + web/autopilot_chat.js | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 7f2fa371..dbdb1cf4 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -143,3 +143,4 @@ .fv-chat-new-error{padding:0 var(--fv-chats-pad) 6px} .fv-chat-me>.fv-chat-actions{margin-top:6px;flex-wrap:wrap;justify-content:flex-end} .fv-chat-me .fv-chat-error{flex:1;min-width:0} +.fv-chat-keep{white-space:nowrap} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index d4fbf48f..5bbe650c 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -242,7 +242,13 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () sendButton.type = 'submit'; sendButton.setAttribute('aria-label', t('Send', '发送')); sendButton.append(glyph('M12 19V5M5 12l7-7 7 7')); form.append(input, sendButton); scroller.append(log); main.append(scroller, down, form); element.append(main); const notice = el('p', 'fv-chat-notice'); notice.hidden = true; notice.setAttribute('role', 'status'); main.insertBefore(notice, form); - function say(text) { notice.textContent = text; notice.hidden = !text; } + // A quoted button name stays on one line, so “新对话” never wraps in the middle. + function quoted(node, text) { + const parts = String(text).split(/(“[^”]{1,12}”|"[^"]{1,24}")/); + node.textContent = text; + if (parts.length > 1) node.replaceChildren(...parts.flatMap((part, index) => part ? [el('span', index % 2 ? 'fv-chat-keep' : '', part)] : [])); + } + function say(text) { quoted(notice, text); notice.hidden = !text; } let dismissImage = null; let state = emptyChat(), pending = [], known = false, stopped = false, timer = null, busy = false, failures = 0; @@ -607,7 +613,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () }); status.append(error, retry); item.append(status); } - error.textContent = entry.error || ''; error.hidden = !entry.error; + quoted(error, entry.error || ''); error.hidden = !entry.error; const full = entry.errorCode === 'session_full'; retry.textContent = full ? t('New chat', '新对话') : t('Retry', '重试'); retry.disabled = entry.sending || (full ? creating : !sendingAllowed()); From b29a85d21606cb40c7f4ecf4b8c4a33d961c08ee Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 03:17:37 +0000 Subject: [PATCH 11/13] docs: add community result row --- docs/community-results.md | 1 + docs/zh-CN/community-results.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/community-results.md b/docs/community-results.md index 481b8653..f38e977a 100644 --- a/docs/community-results.md +++ b/docs/community-results.md @@ -85,6 +85,7 @@ Thanks to the community members who sent diagnostic reports. FreeVideo already r | 3060 | 12 GB | 34 GB | 1664×960 | 6.6 s | Medium | 0.4.8 | 4025 s | 0.4.8 | Already optimal¹² | | 4070 Laptop | 8 GB | 34 GB | 1344×768 | 15 s | Light⁵ | 0.4.8 | 1444 s | 0.4.8 | Already optimal¹² | | 5060 Ti | 16 GB | 56 GB | 960×544 | 10 s | Light | 0.4.6 | 904 s | 0.4.9 | 805 s (−99 s) | +| 3070 Laptop | 8 GB | 64 GB | 1344×768 | 10 s | Light | 0.4.9 | 1267 s | 0.4.9 | Already optimal | - Time: end to end, from submission until the video is saved. - After: the estimated time, derived from the report's stage times and from complete planned runs on our test machines (RTX 3090 and RTX 5060 Ti on Windows, RTX PRO 6000 on Linux) under the reporting machine's VRAM and RAM limits, not from reruns on that machine. diff --git a/docs/zh-CN/community-results.md b/docs/zh-CN/community-results.md index a7050206..db791ab7 100644 --- a/docs/zh-CN/community-results.md +++ b/docs/zh-CN/community-results.md @@ -85,6 +85,7 @@ | 3060 | 12 GB | 34 GB | 1664×960 | 6.6 s | 标⁠准 | 0.4.8 | 4025 s | 0.4.8 | 已⁠是⁠最⁠优¹² | | 4070 Laptop | 8 GB | 34 GB | 1344×768 | 15 s | 轻⁠量⁵ | 0.4.8 | 1444 s | 0.4.8 | 已⁠是⁠最⁠优¹² | | 5060 Ti | 16 GB | 56 GB | 960×544 | 10 s | 轻⁠量 | 0.4.6 | 904 s | 0.4.9 | 805 s (−99 s) | +| 3070 Laptop | 8 GB | 64 GB | 1344×768 | 10 s | 轻⁠量 | 0.4.9 | 1267 s | 0.4.9 | 已⁠是⁠最⁠优 | - 用时:从提交到视频保存完成的端到端用时。 - 优化后:预估用时,根据报告中的分段用时,结合测试机(Windows RTX 3090 和 RTX 5060 Ti、Linux RTX PRO 6000)在报告机器的显存和内存上限下运行完整规划的实测推算,不是在报告机器上重跑。 From 36acd45be05dba44a48818e8b21e65d575c9b9a1 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 06:17:17 +0000 Subject: [PATCH 12/13] Accept workflows saved before the Step cache input and sample them in full A workflow saved by the previous version holds an empty value where the new Step cache option now sits, so every run of it was refused before starting. The node now takes any value that is not an option as no cache, and the page cleans the value when the workflow is loaded and before a request is sent. --- freevideo_engine/comfy_nodes.py | 9 ++++++--- web/optional_inputs.js | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index 2df1d2b3..f78f155e 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -16,8 +16,9 @@ def step_cache_option(value): - """Bridge keyword for the Step cache widget; a workflow without it, or with 'none', adds nothing.""" - return {} if value in (None, 'none') else {'step_cache': value} + """Bridge keyword for the Step cache widget; a workflow without it, with 'none' or with a value that is + not an option adds nothing. Workflows saved before the widget existed hold '' in its position.""" + return {'step_cache': value} if value in STEP_CACHE_OPTIONS and value != 'none' else {} class FreeVideoGenerate(io.ComfyNode): @@ -89,9 +90,11 @@ def define_schema(cls): @classmethod def validate_inputs(cls, text, width, height, seconds, seed, continue_from='', frames=0, two_pass=True, - preview=False, refine_from='', base_steps=8, refine_steps=3): + preview=False, refine_from='', base_steps=8, refine_steps=3, step_cache='none'): # ComfyUI marks every input named here when a check fails, so only the checked widgets are # listed. Linked pictures, media and conditioning have no value before the run anyway. + # step_cache is named so ComfyUI skips its option list: an older saved workflow puts '' there, + # and execute samples in full for any value that is not an option. refine_from = optional_text(refine_from) try: options = request_options(continue_from, frames) diff --git a/web/optional_inputs.js b/web/optional_inputs.js index 240a3b0c..b089de3e 100644 --- a/web/optional_inputs.js +++ b/web/optional_inputs.js @@ -2,7 +2,9 @@ // shape. A workflow restored from an older version, or a widget list that moved, // can leave an empty or missing value in them, and Comfy then rejects the whole // request. Keep each of them to a value the server accepts. -const DEFAULTS = {refine_from: '', continue_from: '', frames: 0, first_frame_reference: false}; +const DEFAULTS = {refine_from: '', continue_from: '', frames: 0, first_frame_reference: false, step_cache: 'none'}; +// A workflow saved before Step cache existed holds '' where it now sits. +const STEP_CACHE_OPTIONS = ['none', 'easycache-av', 'fbcache']; // The words a script, an older workflow or String(null) leaves behind for "not given". const EMPTY_WORDS = new Set(['none', 'null', 'undefined', 'nan']); @@ -18,6 +20,7 @@ export function optionalInputValue(name, value) { if (name === 'frames') return Number.isSafeInteger(value) && value >= 0 ? value : 0; if (name === 'refine_from' || name === 'continue_from') return optionalText(value); if (name === 'first_frame_reference') return value === true; + if (name === 'step_cache') return STEP_CACHE_OPTIONS.includes(value) ? value : 'none'; return value; } From f8a2da8ae3eb928116612f582613c05140ae5447 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 06:37:37 +0000 Subject: [PATCH 13/13] Release 0.4.10 --- freevideo_engine/release_notes.json | 143 +++++++++++----------------- 1 file changed, 58 insertions(+), 85 deletions(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index add3d221..743394c9 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,72 @@ { "schema": 1, - "product_version": "0.4.9", + "product_version": "0.4.10", "en": { - "summary": "Quality levels are reworked: Medium is one pass of 8 steps, and High and Max use step caches to finish much sooner; decoding on Windows reads ahead earlier on slow drives, and the editor handles ranges, splits and text tracks more reliably.", + "summary": "High on Mac uses the step cache and finishes much sooner; in the editor, audio clips can move to another audio track, and several notices and narrow-window layouts are clearer; workflows saved by an earlier version generate again.", "changes": [ - "Quality levels: Medium is one pass of 8 steps; High and Max run 20 steps with step caches and finish much sooner than plain 20 steps.", - "Decoding on Windows: read-ahead is sized by physical memory and starts earlier on slow drives, so the end of a run waits less for the disk.", - "When Windows runs out of virtual memory, in any system language, FreeVideo now retries the generation automatically in a fresh process and reports a memory shortage if it still fails.", - "The progress estimate counts reference images, so requests with reference images show a closer total time from the start.", - "Editor: dragging a range's left edge to the start of the timeline no longer pushes its right edge later; splitting a sped-up or reversed part keeps the fade-in only on the first half and the fade-out only on the second.", - "Editor: drag-selecting parts is smooth again on timelines with many parts.", - "Editor text: clicking the side Content box right after pressing T keeps the new text track, and dropping text onto a locked text track says the track is locked.", - "Long videos: each part's description can also be edited in a larger window.", - "Chat: when a conversation is full, the notice appears once under the message and its button starts a new chat instead of retrying.", - "Export: the sizes offered in the export dialog are rounded the same way as the saved work.", - "Diagnostic reports record how much of the graphics card other programs were using during each sampling step.", - "Launcher: when the browser cannot be opened automatically, the message says to click Copy address and open it in a browser, in Chinese as well. Canvas margin labels stay readable and aligned. Nodes: width, height, duration and seed have Chinese labels." + "Mac: High uses the step cache on the first pass and finishes much sooner, and the starting time estimate is based on the chip, so it is closer from the first step.", + "Editor: an audio clip dragged from the library onto the second audio track lands there, and a single audio clip on the timeline can be dragged to another audio track.", + "Editor: pressing split or trim while the playhead is outside the selected part, or asking for full screen where the browser refuses it, now says why in one line.", + "Character mentions written back to back, with no space between the names, are each recognised, and the character list opens again right after a name.", + "Autopilot: while a task is running, the Autopilot button in the header stays whole and clickable in narrow windows.", + "Chat: on phones and other narrow windows, button names quoted in notices stay on one line.", + "Diagnostic reports for failed image and video requests record the available memory when the request started and when the generation process ended, and whether the system appears to have ended the process.", + "Nodes: the English Width, Height and Seed labels are capitalised like the other fields.", + "Generating from a workflow saved by an earlier version, or from a restored tab, no longer fails with an invalid step cache setting." ] }, "zh": { - "summary": "质量档位调整:标准为单遍 8 步,精细和极致用步间缓存,快得多;Windows 解码在慢硬盘上更早预读;编辑器的区间、切分和文字轨更可靠。", + "summary": "Mac 精细档使用步间缓存,快得多;编辑器里音频片段可以拖到另一条音频轨;几处提示和窄窗口布局更清楚;旧版本保存的工作流又能正常生成。", "changes": [ - "质量档位:标准为单遍 8 步;精细和极致为 20 步加步间缓存,比纯 20 步快得多。", - "Windows 解码:预读按物理内存决定大小,并在慢硬盘上更早开始,生成收尾时等硬盘的时间更短。", - "Windows 虚拟内存耗尽时,不论系统语言,FreeVideo 现在会在新进程中自动重试生成,仍失败时提示内存不足。", - "进度估计把参考图计入,带参考图的请求一开始就显示更接近实际的总时间。", - "编辑器:把区间左边拖到时间线开头,右边不再被往后推;切分加速或倒放的片段时,淡入只留在前半段,淡出只留在后半段。", - "编辑器:片段很多时,框选重新变得流畅。", - "编辑器文字:按 T 后马上点侧边“内容”框,新文字轨会保留;把文字拖到已锁定的文字轨上会提示该轨已锁定。", - "长视频:每段描述也能在放大窗口里编辑。", - "对话:对话已满时,提示只在消息下出现一次,按钮换成“新对话”。", - "导出:导出窗口里给出的尺寸和保存的作品用同样的方式取整。", - "诊断报告记录每一步采样时其他程序占用显卡的比例。", - "启动器:浏览器没能自动打开时,提示点“复制地址”到浏览器里打开,中文界面也显示中文。画布边距标签更清楚、对齐。节点:宽度、高度、时长和种子有中文标签。" + "Mac:精细档第一遍使用步间缓存,快得多;开始时的预计时间按芯片给出,从第一步起就更接近实际。", + "编辑器:从素材库把音频拖到第二条音频轨会放在该轨;时间线上单个音频片段可以拖到另一条音频轨。", + "编辑器:播放头不在所选片段上时按切分或修剪,或在浏览器不允许全屏的地方点全屏,会用一行字说明原因。", + "连着写、名字之间没有空格的角色提及都能被识别,写完一个名字后接着输入也会弹出角色列表。", + "AI 全自动:有任务在跑时,窄窗口里顶栏的“AI 全自动”按钮也始终完整可点。", + "对话:手机等窄窗口里,提示中引号内的按钮名不再被拆到两行。", + "图像和视频失败请求的诊断报告现在记录请求开始和生成进程结束时的可用内存,并标明进程是否疑似被系统结束。", + "节点:英文界面里 Width、Height、Seed 的大小写与其他项一致。", + "修复:用旧版本保存的工作流或恢复的标签页生成时提示步间缓存参数无效。" ] }, "history": [ + { + "product_version": "0.4.9", + "en": { + "summary": "Quality levels are reworked: Medium is one pass of 8 steps, and High and Max use step caches to finish much sooner; decoding on Windows reads ahead earlier on slow drives, and the editor handles ranges, splits and text tracks more reliably.", + "changes": [ + "Quality levels: Medium is one pass of 8 steps; High and Max run 20 steps with step caches and finish much sooner than plain 20 steps.", + "Decoding on Windows: read-ahead is sized by physical memory and starts earlier on slow drives, so the end of a run waits less for the disk.", + "When Windows runs out of virtual memory, in any system language, FreeVideo now retries the generation automatically in a fresh process and reports a memory shortage if it still fails.", + "The progress estimate counts reference images, so requests with reference images show a closer total time from the start.", + "Editor: dragging a range's left edge to the start of the timeline no longer pushes its right edge later; splitting a sped-up or reversed part keeps the fade-in only on the first half and the fade-out only on the second.", + "Editor: drag-selecting parts is smooth again on timelines with many parts.", + "Editor text: clicking the side Content box right after pressing T keeps the new text track, and dropping text onto a locked text track says the track is locked.", + "Long videos: each part's description can also be edited in a larger window.", + "Chat: when a conversation is full, the notice appears once under the message and its button starts a new chat instead of retrying.", + "Export: the sizes offered in the export dialog are rounded the same way as the saved work.", + "Diagnostic reports record how much of the graphics card other programs were using during each sampling step.", + "Launcher: when the browser cannot be opened automatically, the message says to click Copy address and open it in a browser, in Chinese as well. Canvas margin labels stay readable and aligned. Nodes: width, height, duration and seed have Chinese labels." + ] + }, + "zh": { + "summary": "质量档位调整:标准为单遍 8 步,精细和极致用步间缓存,快得多;Windows 解码在慢硬盘上更早预读;编辑器的区间、切分和文字轨更可靠。", + "changes": [ + "质量档位:标准为单遍 8 步;精细和极致为 20 步加步间缓存,比纯 20 步快得多。", + "Windows 解码:预读按物理内存决定大小,并在慢硬盘上更早开始,生成收尾时等硬盘的时间更短。", + "Windows 虚拟内存耗尽时,不论系统语言,FreeVideo 现在会在新进程中自动重试生成,仍失败时提示内存不足。", + "进度估计把参考图计入,带参考图的请求一开始就显示更接近实际的总时间。", + "编辑器:把区间左边拖到时间线开头,右边不再被往后推;切分加速或倒放的片段时,淡入只留在前半段,淡出只留在后半段。", + "编辑器:片段很多时,框选重新变得流畅。", + "编辑器文字:按 T 后马上点侧边“内容”框,新文字轨会保留;把文字拖到已锁定的文字轨上会提示该轨已锁定。", + "长视频:每段描述也能在放大窗口里编辑。", + "对话:对话已满时,提示只在消息下出现一次,按钮换成“新对话”。", + "导出:导出窗口里给出的尺寸和保存的作品用同样的方式取整。", + "诊断报告记录每一步采样时其他程序占用显卡的比例。", + "启动器:浏览器没能自动打开时,提示点“复制地址”到浏览器里打开,中文界面也显示中文。画布边距标签更清楚、对齐。节点:宽度、高度、时长和种子有中文标签。" + ] + } + }, { "product_version": "0.4.8", "en": { @@ -173,64 +204,6 @@ "浏览器翻译页面时不再翻译产品名。" ] } - }, - { - "product_version": "0.4.4", - "en": { - "summary": "Continuing a video segment by segment no longer stops with a memory error, medium-size videos on Windows with 16 GB or smaller cards are fast again in the second pass, and image generation on Windows no longer stalls. The editing view gains reference pictures and imported-video continuation, and several issues that stopped generation or setup are fixed.", - "changes": [ - "Continuing a video segment by segment no longer stops with a memory error from the second segment on.", - "Windows: medium-size videos on graphics cards with 16 GB or less no longer slow down in the second pass.", - "Image generation: on Windows it no longer stalls while the image model starts, reports a false out-of-memory error after loading, or stalls while checking downloaded model files; tall pictures no longer show purple vertical lines or a grid texture.", - "After a video finishes, FreeVideo releases graphics and system memory right away unless the next part is about to start, and gives way at once to image generation or other programs.", - "Editing view: the generation bar takes reference pictures (upload one or make one with the image model, then set it as reference, first frame or last frame), every Generate press answers at once, and queued parts stay queued after the page is reloaded and start by themselves; if generation cannot run, they go back to drafts with the reason.", - "Editing view: videos imported from your computer can be extended and continued, files dropped on the timeline land where they are dropped, reopening it from the launcher keeps it usable, and keys pressed in the media library no longer change the timeline.", - "Mac: generating with reference pictures no longer runs out of memory while encoding; the compatibility setting, which only applies to separate graphics cards, is no longer shown.", - "Additional model folders entered in the node settings are now saved and used when FreeVideo looks for models and starts.", - "Edge model files now download from the ModelScope domestic site, and the image model gains an additional mirror source.", - "Image editor: leaving or reloading the page with unsaved changes now asks first. Flipping, resetting, fitting or dragging the locked original works on an automatic copy as one undo step. Renaming a layer by double-click keeps the name, and invalid canvas sizes are explained instead of failing silently.", - "Small mismatches no longer stop the launcher, setup, saved Agent films and cast cards, subtitle import, the image editor, sharing or prompt enhancement; FreeVideo continues and repairs what it can. Turning off or cancelling prompt enhancement while it runs stops it cleanly, and the release link in the update notice opens the latest release.", - "A workflow restored with an empty Total frames value no longer fails with a frame-count error; it generates by duration." - ] - }, - "zh": { - "summary": "一段接一段连续生成不再因内存报错;Windows 上 16 GB 及以下显卡生成中等尺寸视频,第二遍恢复原来的速度;Windows 图像生成不再卡住。剪辑视图可以加参考图、接续导入的视频,另修复了几处会让生成或安装中断的问题。", - "changes": [ - "一段接一段连续生成时,从第二段起不再因内存不足报错。", - "Windows:16 GB 及以下显卡生成中等尺寸视频时,第二遍不再变慢。", - "图像生成:Windows 上图像模型启动时不再卡住、加载完不再误报内存不足,下载后校验文件不再卡住;高图上不再出现紫色竖线和网格纹。", - "视频生成完会立即释放显存和内存;下一段马上开始时才短暂保留,遇到图像生成或其他程序要用时立刻让出。", - "剪辑视图:生成条可以加参考图(上传图片或用图像模型生成一张,可设为参考、首帧或尾帧);每次点“生成”都会立刻有反应,排队的段刷新页面后仍在排队、轮到时自动开始,不能生成时退回草稿并说明原因。", - "剪辑视图:从电脑导入的视频也能往后接续生成,拖到时间线上的文件落在松手的位置;从启动入口再次进入时可以正常操作,素材库里按键不再改动时间线。", - "Mac:用参考图生成时,编码阶段不再报内存不足;只对独立显卡起作用的兼容性设置不再显示。", - "节点设置里填写的“其他模型目录”现在会保存,查找模型和启动时都会用到。", - "Edge 模型文件改从魔搭国内站下载,图像模型增加一个镜像下载源。", - "图片编辑器:有未保存的改动时离开或刷新页面会先提示;对已锁定的原图翻转、重置、适配或拖动时,自动在副本上操作并算一步撤销;双击重命名图层保留名称;画布尺寸不合法时给出提示而不是无反应。", - "启动器、安装、Agent 已存的影片和角色卡、字幕导入、图片编辑器、分享和提示词改写遇到小偏差不再中断,FreeVideo 会继续并自动修复能修的部分;提示词增强运行中关闭或取消时会正常停止,更新提示里的发布页链接打开最新版本。", - "恢复的工作流把“总帧数”留空时,生成不再因帧数校验报错,按时长正常生成。" - ] - } - }, - { - "product_version": "0.4.3", - "en": { - "summary": "LAN access settings of offline-package installations are kept when the launcher is reopened, and the agent no longer replies after a job is stopped on the page. Also includes several fixes; the image generation models can now be downloaded and installed normally.", - "changes": [ - "With an offline-package installation, LAN access and its port now stay as set after the launcher is reopened.", - "After a job is stopped on the page, the agent adds no message about it and does not retry on its own.", - "When the agent asks for a video length this computer cannot make, it now gets a clear explanation instead of an error.", - "The image generation models can now be downloaded and installed normally." - ] - }, - "zh": { - "summary": "用离线包安装时,局域网访问的设置在重开启动器后保留;在页面上停止任务后,Agent 不再另外回复;另有几处修复,图像生成模型现在可以正常下载和安装。", - "changes": [ - "用离线包安装时,局域网访问的开关和端口在重开启动器后保持不变。", - "在页面上停止任务后,Agent 不再另外回复,也不会自行重试。", - "Agent 请求的视频长度超出这台电脑能做的范围时,会收到明确的说明,不再出错。", - "图像生成模型现在可以正常下载和安装。" - ] - } } ] }