Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/community-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/zh-CN/community-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)在报告机器的显存和内存上限下运行完整规划的实测推算,不是在报告机器上重跑。
Expand Down
7 changes: 7 additions & 0 deletions freevideo_engine/adaptive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions freevideo_engine/agent/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 1 addition & 1 deletion freevideo_engine/agent/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
4 changes: 3 additions & 1 deletion freevideo_engine/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions freevideo_engine/comfy_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
15 changes: 9 additions & 6 deletions freevideo_engine/comfy_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -31,12 +32,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,
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions freevideo_engine/diagnostic_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
8 changes: 6 additions & 2 deletions freevideo_engine/diagnostic_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
Expand Down
5 changes: 4 additions & 1 deletion freevideo_engine/effort_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
127 changes: 127 additions & 0 deletions freevideo_engine/exit_diagnostics.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions freevideo_engine/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading