Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c992a18
The command line generates correctly from a deep install root
KBRASK Oct 22, 2026
f9b987c
Image history entries can be deleted from the library
KBRASK Oct 22, 2026
871575a
Windows int8: take eight attention heads on 12 GB cards beyond 72576 …
KBRASK Oct 22, 2026
f6f4207
Eight heads and a second transfer slot for FP8 first passes in the Wi…
KBRASK Oct 22, 2026
8d10583
Editor: never send an empty description when continuing a finished video
KBRASK Oct 22, 2026
603911d
Prompt enhancement retries missing media and names the fallback reason
KBRASK Oct 22, 2026
7e19ac7
Pinned host copies fall back to a plain buffer after a CUDA copy error
KBRASK Oct 22, 2026
65b3edc
A missing or failing nvidia-smi falls back to the CUDA driver reading
KBRASK Oct 22, 2026
d6de4f9
Issue template asks for the diagnostic report
KBRASK Oct 22, 2026
f98d3db
Errors with a diagnostic report submit through the report route
KBRASK Oct 22, 2026
167ac12
Revert: Pinned host copies fall back to a plain buffer after a CUDA c…
KBRASK Oct 22, 2026
e9e8d61
Revert: Eight heads and a second transfer slot for FP8 first passes i…
KBRASK Oct 22, 2026
b7dca0c
Revert: Windows int8: take eight attention heads on 12 GB cards beyon…
KBRASK Oct 22, 2026
601d4a0
Test persistent model check cache failure paths
KBRASK Oct 22, 2026
bdce0aa
Test queued chat edit notices and retry recovery
KBRASK Oct 22, 2026
c4b91c7
Keep the fixed failure details of a GPU error that raised the compati…
KBRASK Oct 22, 2026
72f9694
Bundle static curl for Linux AppImage downloads
KBRASK Oct 22, 2026
8310c35
Release 0.4.19
KBRASK Oct 22, 2026
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
9 changes: 7 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_engine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,10 @@ body:
- type: textarea
id: report
attributes:
label: Error or local report (optional) / 错误详情或本地报告(选填)
description: Attach the diagnostic report (FreeVideo-report-….json, or video.debug.json in the output folder) if available; it includes hardware and timing information. / 如有诊断报告(FreeVideo-report-….json,或输出文件夹里的 video.debug.json),可拖入此处;其中已包含硬件和耗时信息。
label: Diagnostic report / 诊断报告
description: |
In FreeVideo, select Diagnostic report under a finished video. If the generation failed, select Report a problem on the error message. The report (FreeVideo-report-….json or .zip) is saved in your browser's download folder. Drag it here. For FreeVideo before 0.3.10, use video.debug.json in the output folder.
在 FreeVideo 中,视频生成完成后点击视频下方的「诊断报告」;生成失败时点击错误提示上的「提交问题」。报告(FreeVideo-report-….json 或 .zip)保存在浏览器的下载文件夹,请将它拖到这里。0.3.10 之前的版本使用输出文件夹里的 video.debug.json。
placeholder: Drop FreeVideo-report-….json or .zip here / 将 FreeVideo-report-….json 或 .zip 拖到这里
validations:
required: true
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/MsA277cJzZ
Expand Down
1 change: 1 addition & 0 deletions freevideo_engine/adaptive.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,7 @@ def execute_attempts(history, identity, profile, canvas, launch, *,
row['seconds'] = time.perf_counter() - started
if isinstance(metrics, dict):
row['metrics'] = attempt_metrics(metrics)
details['completed_steps'] = row['metrics']['completed_steps']
history.finish(attempt, failure['outcome'], details=details)
row.update(state=failure['outcome'], failure=failure)
persist()
Expand Down
83 changes: 73 additions & 10 deletions freevideo_engine/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,16 +301,46 @@ def digest(path, algorithm='sha256', git_blob=False):


def inventory(gpu=None):
smi = nvidia_smi()
query = 'index,uuid,name,compute_cap,memory.total,memory.free,driver_version,pci.bus_id'
result = subprocess_output.run_external([smi, '--query-gpu=' + query, '--format=csv,noheader,nounits'],
check=True, capture_output=True, timeout=20)
rows = [dict(zip(query.split(','), map(str.strip, row))) for row in csv.reader(result.stdout.splitlines())]
selector = gpu if gpu is not None else os.environ.get('CUDA_VISIBLE_DEVICES', '0').split(',')[0]
matches = [r for r in rows if r['index'] == selector or r['uuid'] == selector]
if len(matches) != 1:
raise ValueError('Select one physical GPU by nvidia-smi index or full UUID with --gpu. MIG is not supported.')
selected = matches[0]
detection = {}
try:
smi = nvidia_smi()
result = subprocess_output.run_external([smi, '--query-gpu=' + query, '--format=csv,noheader,nounits'],
check=True, capture_output=True, timeout=20)
rows = [dict(zip(query.split(','), map(str.strip, row))) for row in csv.reader(result.stdout.splitlines())]
if not rows or any(set(row) != set(query.split(',')) for row in rows):
raise ValueError('nvidia-smi returned an incomplete GPU inventory')
# Unsupported numeric fields are also a failed observation, not proof
# that the user has no GPU. Only the selected row has to be readable;
# another GPU reporting N/A must not block an installation that worked.
matches = [r for r in rows if r['index'] == selector or r['uuid'] == selector]
if len(matches) == 1:
row = matches[0]
float(row['memory.total']), float(row['memory.free'])
int(row['driver_version'].split('.')[0])
capability = tuple(map(int, row['compute_cap'].split('.')))
if len(capability) != 2 or capability[0] <= 0 or not 0 <= capability[1] <= 9:
raise ValueError('nvidia-smi returned an unknown compute capability: ' + row['compute_cap'])
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error:
from . import cuda_inventory
try:
fallback = cuda_inventory.probe(selector)
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as fallback_error:
raise RuntimeError('GPU detection failed. nvidia-smi: %s; CUDA: %s. '
'Check device access and the NVIDIA driver, then retry setup.' %
(error, fallback_error)) from fallback_error
selected, rows = fallback['selected_gpu'], fallback['gpus']
detection = dict(cuda_driver_api_version=fallback['cuda_driver_api_version'],
warnings=[cuda_inventory.WARNING],
gpu_detection=dict(source='cuda-driver', nvidia_smi_error=str(error),
nvidia_smi_returncode=getattr(error, 'returncode', None),
nvidia_smi_stderr=getattr(error, 'stderr', None)))
logging.getLogger(__name__).info('%s nvidia-smi: %s', cuda_inventory.WARNING, error)
else:
if len(matches) != 1:
raise ValueError('Select one physical GPU by nvidia-smi index or full UUID with --gpu. MIG is not supported.')
selected = matches[0]
try:
capability = tuple(map(int, selected['compute_cap'].split('.')))
if len(capability) != 2:
Expand All @@ -332,7 +362,7 @@ def inventory(gpu=None):
'swap_free_bytes': ram.get('swap_free_bytes'), 'system_memory': ram,
'compiler': shutil.which('cl' if platform.system() == 'Windows' else 'g++'), 'git': usable_git(),
**({} if platform.system() == 'Windows' else {'make': shutil.which('make')}),
'git_found': shutil.which('git')}
'git_found': shutil.which('git'), **detection}


# Windows reuses a detected Git from 2.35, the oldest tested there, and
Expand Down Expand Up @@ -522,6 +552,7 @@ def plan(args, *, local_progress=None, stage=None):
if not args.hardware_json and (platform.system() not in ('Linux', 'Windows') or platform.machine().lower() not in ('x86_64', 'amd64')):
raise RuntimeError('One-click setup supports Linux x86_64 and native Windows x64.')
snapshot = json.loads(args.hardware_json.read_text(encoding='utf-8')) if args.hardware_json else inventory(args.gpu)
warnings.extend(snapshot.get('warnings', []))
hardware = Hardware.from_dict(snapshot['hardware'])
errors = []
if snapshot.get('machine', '').lower() not in ('x86_64', 'amd64') or hardware.system not in ('Linux', 'Windows'):
Expand All @@ -535,7 +566,8 @@ def plan(args, *, local_progress=None, stage=None):
driver = str(snapshot['selected_gpu']['driver_version']).strip()
# An unreadable version is left to the later checks instead of blocking setup.
major = re.match(r'\d+', driver)
if major and int(major.group()) < 580:
cuda_api = snapshot.get('cuda_driver_api_version')
if cuda_api < 13000 if cuda_api is not None else (major and int(major.group()) < 580):
# The card names the graphics card too; brackets would end its pattern early.
model = re.sub(r'[()\r\n]', '', str(snapshot['selected_gpu'].get('name') or '')).strip()
errors.append('NVIDIA driver %s%s is older than 580, the minimum for the CUDA 13 encoder; update the driver first.'
Expand Down Expand Up @@ -628,6 +660,15 @@ def plan(args, *, local_progress=None, stage=None):
approved = getattr(args, 'approved_plan', None)
if approved:
previous_reuse = json.loads(Path(approved).read_text(encoding='utf-8')).get('local_models')
scan_cache = root / 'local-model-scan.json'
if not previous_reuse and scan_cache.is_file():
# What an earlier check hashed; scan() still hashes any file whose size, times or inode changed.
try:
previous_reuse = json.loads(scan_cache.read_text(encoding='utf-8'))
except (OSError, ValueError):
previous_reuse = None
if not (isinstance(previous_reuse, dict) and isinstance(previous_reuse.get('matches'), dict)):
previous_reuse = None
needed = [row for row in files if not model_target(row, model_dir, encoder_dir, prepared_dir).exists()]
if local_manifest:
if Path(local_manifest).stat().st_size > 512 * 1024:
Expand Down Expand Up @@ -655,6 +696,11 @@ def plan(args, *, local_progress=None, stage=None):
if error.__cause__ is not None:
original += '\n' + str(error.__cause__)
raise DownloadListError('read', original_error=original) from error
if local_reuse['matches'] and root.is_dir():
try:
save(scan_cache, dict(root=local_reuse['root'], matches=local_reuse['matches']))
except OSError:
pass # The next check hashes again; nothing depends on this file.
local_reuse['copy_mode'] = bool(getattr(args, 'copy_existing_models', False))
local_reuse['copy_bytes'] = local_reuse['linked_bytes'] = 0
for row in needed:
Expand All @@ -673,6 +719,16 @@ def plan(args, *, local_progress=None, stage=None):
check_download_list = bool(getattr(args, 'download_list', False))
stamps_path = root / 'verified-models.json'
stamps = json.loads(stamps_path.read_text(encoding='utf-8')) if check_download_list and stamps_path.is_file() else {}
# The check leaves the installer's ledger alone and keeps what it hashed in a file of its own.
checked_path = root / 'download-list-checks.json'
try:
checked = json.loads(checked_path.read_text(encoding='utf-8')) if check_download_list and checked_path.is_file() else {}
except (OSError, ValueError):
checked = {}
ledger_stamps = dict(stamps)
# A record this check made is newer than the ledger's for the same file whenever the two differ.
stamps = dict(ledger_stamps, **(checked if isinstance(checked, dict) else {}))
checked_paths = set()
for row in files:
path = model_target(row, model_dir, encoder_dir, prepared_dir)
replacement = model_repair.replacement(path, row, mismatches, reserved)
Expand All @@ -685,6 +741,7 @@ def plan(args, *, local_progress=None, stage=None):
from .provision import verified
hf_directory = Path(prepared_dir) if row.get('prepared') else model_dir if row['repo'].startswith('OpenVDN/') else encoder_dir
size_matches = verified(path, row, stamps, mode=getattr(args, 'verify', 'auto'), hf_directory=hf_directory)
checked_paths.add(str(path))
present += row['bytes'] if size_matches else 0
device = existing_parent(path.parent)
entry = groups.setdefault(str(device), {'path': str(device), 'needed_bytes': 0,
Expand All @@ -697,6 +754,12 @@ def plan(args, *, local_progress=None, stage=None):
# Only the streaming strategy can reuse this contiguous prefix.
# Its completed content must still pass the pinned hash check.
entry['resume_bytes'] = entry.get('resume_bytes', 0) + partial.stat().st_size
remembered = {name: value for name, value in stamps.items() if name in checked_paths and ledger_stamps.get(name) != value}
if check_download_list and root.is_dir() and remembered != checked:
try:
save(checked_path, remembered) # The next check does not hash these files again unless they change.
except OSError:
pass
# Stream directly into final FP8 groups. The pinned model occupies ~45.3
# GiB; allow 52 GiB including the largest group in progress. Never credit
# future source deletion toward the space needed to complete conversion.
Expand Down
7 changes: 7 additions & 0 deletions freevideo_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ def main():
if len(sys.argv) > 1 and sys.argv[1] in ('projects', 'sessions'):
from .agent.cli import main as agent
return agent(sys.argv[1:])
if not getattr(sys, 'frozen', False) and not {'-h', '--help'} & set(sys.argv[1:]):
from .launch_view import restart_arguments
restart = restart_arguments(sys.argv[1:])
if restart is not None:
# Deep installation folder: Windows cannot load the libraries by their full path.
from . import processes
raise SystemExit(processes.run(restart[0], env=restart[1], start_new_session=False).returncode)
if len(sys.argv) > 1 and sys.argv[1] == 'predict':
from .prediction_cli import main as predict
raise SystemExit(predict(sys.argv[2:]))
Expand Down
5 changes: 4 additions & 1 deletion freevideo_engine/comfy_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ def isolated_environment(root, source, environ=None):
env.update(FREEVIDEO_HOME=str(root), FREEVIDEO_PROXY_MODE=proxy_mode, PYTHONPATH=str(source), PYTHONNOUSERSITE='1',
PYTHONUTF8='1', PYTHONIOENCODING='utf-8', NO_COLOR='1', FREEVIDEO_UI_EVENTS='1')
from .triton_compat import temporary_environment
return temporary_environment(root, env)
# Pruning sys.prefix above also prunes the frozen launcher's static curl.
# Restore its fallback for managed Python after isolating Python packages.
from .linux_bundle import child_environment
return temporary_environment(root, child_environment(env))
51 changes: 50 additions & 1 deletion freevideo_engine/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,50 @@ def current_settings(value):
return value


def failure_evidence(details):
# Bookkeeping must never turn a settled failure into another error.
try:
return _failure_evidence(details)
except Exception:
return None


def _failure_evidence(details):
"""What a discarded GPU failure leaves for diagnosis: fixed fields and numbers only.

The attempt row is deleted when it is discarded, so a report taken after a
later successful retry would otherwise show no more than the reason.
"""
if not isinstance(details, dict):
return None
evidence = {}
for key in ('failed_phase', 'completed_steps'):
value = details.get(key)
if isinstance(value, str) and len(value) <= 64 or type(value) is int:
evidence[key] = value
chain = []
for item in (details.get('exception') or [])[:3]:
if not isinstance(item, dict):
continue
entry = {key: item[key] for key in ('exception_type', 'category') if isinstance(item.get(key), str)
and len(item[key]) <= 64}
if type(item.get('error_code')) is int:
entry['error_code'] = item['error_code']
entry['frames'] = [{key: (str(frame[key]).replace('\\', '/').rsplit('/', 1)[-1] if key == 'file' else frame[key])
for key in ('file', 'function', 'line')
if isinstance(frame.get(key), (str, int)) and len(str(frame[key])) <= 96}
for frame in (item.get('frames') or [])[-4:] if isinstance(frame, dict)]
chain.append(entry)
if chain:
evidence['exception'] = chain
memory = details.get('memory')
if isinstance(memory, dict):
numbers = {key: value for key, value in memory.items() if type(value) is int}
if numbers:
evidence['memory'] = dict(list(numbers.items())[:16])
return evidence or None


def device_key(identity):
"""Missing optional metadata disables compatibility, never invents a device."""
if not isinstance(identity, dict):
Expand Down Expand Up @@ -174,7 +218,7 @@ def acknowledge(self, identity, notice_id):
self.write_row(db, key, value)
return dict(value, levels=LEVELS, available=True)

def incident(self, row, reason, batch=None):
def incident(self, row, reason, batch=None, evidence=None):
# Only a launched video worker can be a load-related interrupted attempt.
if row.get('purpose') != 'generation' or not row.get('worker'):
return
Expand All @@ -191,6 +235,9 @@ def incident(self, row, reason, batch=None):
config=row['config'], geometry=row['geometry'], diagnosis='unknown', time=time.time(),
policy_revision=revision, applicable=revision == policy_revision(),
counted=False, level_changed=False)
evidence = failure_evidence(evidence)
if evidence:
incident['evidence'] = evidence
escalate = False
if value['automatic'] and incident['applicable']:
if reason == 'cuda_error':
Expand Down Expand Up @@ -225,6 +272,8 @@ def incident(self, row, reason, batch=None):
value['notice'] = dict(id=row['id'], level=value['level'], reason=reason,
phase=row.get('phase'), diagnosis='unknown', policy_revision=revision)
value['trigger'] = dict(value['notice'])
if evidence:
value['trigger']['evidence'] = evidence
db.execute('INSERT INTO incidents VALUES (?,?,?,?)', (row['id'], key, incident['time'], json.dumps(incident)))
self.write_row(db, key, value)
db.execute('DELETE FROM incidents WHERE device=? AND id NOT IN '
Expand Down
Loading