Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e2a00dd
Load the image model from read-only mappings when a Windows machine h…
KBRASK Nov 3, 2026
9ca65cc
Retry once with a fresh worker after a native crash while an image mo…
KBRASK Nov 3, 2026
2f737c5
Say where access was denied on the launcher's failure card, and keep …
KBRASK Nov 3, 2026
e2ff59b
Licenses: Apache-2.0 text for the music text encoder; music model row…
KBRASK Nov 3, 2026
138e66b
Show stale prompt rewrites as neutral notices
KBRASK Nov 3, 2026
7d50461
Continue in pageable memory when a copy through a locked residual or …
KBRASK Nov 3, 2026
38d5972
Reserve non-local room for the locked residual buffer of large requests
KBRASK Nov 3, 2026
81eafd1
Prompt enhancement names the prompt length and the next step when it …
KBRASK Nov 3, 2026
34a8f1c
Warn when other programs worsen generation memory placement
KBRASK Nov 3, 2026
b2104e6
Tests: the image model card says verifying for a finished download an…
KBRASK Nov 3, 2026
cec987d
Name the language and item when release notes are rejected
KBRASK Nov 3, 2026
455cf8e
Editor playback failure sentence in formal Chinese
KBRASK Nov 3, 2026
2e5662e
Export interruption after a restart in formal wording
KBRASK Nov 3, 2026
0afd06f
Phone access reasons in formal wording
KBRASK Nov 3, 2026
6e268b9
Setup reasons test: read the visible installation status line
KBRASK Nov 3, 2026
8419791
Setup: report the tool's own reason when the isolated Python install …
KBRASK Nov 3, 2026
cf0578b
Per-GPU queue: admit studio and Agent requests, and let a lone oversi…
KBRASK Nov 3, 2026
2d7625c
Remove short folders whose runtime folder no longer exists
KBRASK Nov 3, 2026
0cea53a
The uv tool is fetched again when it has gone missing before an insta…
KBRASK Nov 3, 2026
ed2dd58
Hold one stream-sized buffer at the staged residual boundaries
KBRASK Nov 3, 2026
13a9fe9
Leave allocator slack beside kept models before decoding, dropping de…
KBRASK Nov 3, 2026
53a5cc5
Prompt enhancement fits time points that run past the end of the video
KBRASK Nov 3, 2026
fd6996e
Page check lists the music editor scripts; export the speech ramp on …
KBRASK Nov 3, 2026
bf64e0f
Release notes for 0.4.30
KBRASK Nov 3, 2026
ae835ca
Release notes for 0.4.30 name @ mentions without the handle form
KBRASK Nov 3, 2026
885d971
Release notes for 0.4.30 use plain ASCII dots in English
KBRASK Nov 3, 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
3 changes: 3 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ FreeVideo code uses [Apache-2.0](LICENSE). Its dependencies and models retain th
| [ONNX Runtime](https://github.com/microsoft/onnxruntime) 1.28.2 (CPU), © Microsoft Corporation; its third-party notices are shipped inside the sherpa-onnx wheel | MIT |
| [Eigen](https://gitlab.com/libeigen/eigen) 5.0.1, compiled into sherpa-onnx and ONNX Runtime; source: https://gitlab.com/libeigen/eigen/-/tree/5.0.1 | MPL-2.0 |
| [Silero VAD](https://github.com/snakers4/silero-vad) v5.0 voice-activity model, © 2020-present Silero Team (optional download with subtitles, license file downloaded with the model; the onnx-community export is downloaded with the music model) | [MIT](freevideo_engine/licenses/Silero-VAD-MIT.txt) |
| [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) XL turbo music model, © 2026 ACEStep (optional download for music; inference subset adapted in `freevideo_engine/music/acestep_model.py`) | [MIT](freevideo_engine/licenses/ACE-Step-MIT.txt) |
| [vector-quantize-pytorch](https://github.com/lucidrains/vector-quantize-pytorch), © 2020 Phil Wang (residual FSQ decoding adapted in `freevideo_engine/music/acestep_model.py`) | [MIT](freevideo_engine/licenses/Vector-Quantize-PyTorch-MIT.txt) |
| [Qwen3-Embedding-0.6B](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B), © Alibaba Cloud (text encoder of the music model, downloaded with it) | [Apache-2.0](freevideo_engine/licenses/Qwen3-Embedding-Apache-2.0.txt) |
| [NVIDIA NeMo stt_en_conformer_ctc_small](https://huggingface.co/nvidia/stt_en_conformer_ctc_small), © NVIDIA, exported to ONNX and quantized to int8 by the sherpa-onnx project, changed from the original; not endorsed by NVIDIA (optional download for English subtitles; [notice](freevideo_engine/licenses/NeMo-stt_en_conformer_ctc_small-NOTICE.txt)) | [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/) |
| [OpenAI Whisper tiny](https://github.com/openai/whisper), © 2022 OpenAI, exported to ONNX and quantized to int8 by the sherpa-onnx project (optional download for spoken-language detection) | [MIT](freevideo_engine/licenses/Whisper-MIT.txt) |
| [SenseVoiceSmall](https://huggingface.co/FunAudioLLM/SenseVoiceSmall) by FunASR / FunAudioLLM (Alibaba Group), converted to ONNX and quantized to int8 by the sherpa-onnx project, used unchanged under its original name (optional download for multilingual subtitles; [notice](freevideo_engine/licenses/SenseVoice-NOTICE.txt)) | [FunASR Model Open Source License Agreement v1.1](freevideo_engine/licenses/FunASR-MODEL_LICENSE-v1.1.txt) |
Expand Down
6 changes: 5 additions & 1 deletion freevideo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,14 @@ try {
if ($pythonExit -eq 0) { $installed = $true; break }
if (-not (Test-FreeVideoSlowDownloadFailure $pythonOutput)) { $allNetworkFailures = $false }
Write-Host "Python install source $attempt returned exit code $pythonExit. Details retained in $bootstrapLog"
$pythonReason = Get-FreeVideoFailureReason $pythonOutput
if ($pythonReason) { Write-Host "uv reported: $pythonReason" }
}
}
}
if (-not $installed) { throw "Python installation failed (uv exit $pythonExit). Check your network or connection mode in Downloads. See $bootstrapLog and retry." }
$pythonDetail = ''
if ($pythonReason) { $pythonDetail = " uv reported: $pythonReason." }
if (-not $installed) { throw "Python installation failed (uv exit $pythonExit).$pythonDetail Check your network or connection mode in Downloads. See $bootstrapLog and retry." }
$freevideoPython = (& $uv python find --managed-python $versions.python | Select-Object -Last 1).Trim()
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $freevideoPython)) { throw 'Managed Python could not be located.' }
} finally {
Expand Down
74 changes: 72 additions & 2 deletions freevideo_engine/activation_staging.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,75 @@
"""Contiguous D2H head groups with double-buffered CPU projection tiles."""
import collections
import torch

# Every refused locked-page copy of this process, residual stream and head
# readouts alike, so a report shows whether the fallback fired, which copy was
# refused and what CUDA said. The newest entries are kept; the count is exact.
_REFUSED = collections.deque(maxlen=32)
_refused_total = 0


def record_refusal(source, copy, error):
global _refused_total
lines = str(error).strip().splitlines()
_REFUSED.append(dict(source=source, copy=copy, error=(lines[0] if lines else '')[:200]))
_refused_total += 1


def refusals_mark():
return _refused_total


def refusals_since(mark):
"""Refusals recorded after `mark`, as plain dicts for the sampling metrics."""
count = min(_refused_total - mark, len(_REFUSED))
return [dict(row) for row in list(_REFUSED)[len(_REFUSED) - count:]] if count > 0 else []


class LockedCopies:
"""Asynchronous copies through page-locked buffers that continue in pageable memory.

Windows can refuse a queued copy with "invalid argument" when the system
takes locked pages back. The queued work is finished first, so a sticky
error is raised as itself rather than hidden by the retry. The copy then
goes through a pageable temporary, which holds the same bytes, and the
remaining copies of these buffers skip the locked path. A failed device
allocation is not a refusal: it is raised for the caller to recover from,
and the buffers stay locked.
"""

def __init__(self):
self.locked = True
self.fallbacks = 0

def _leave(self, device, copy, error):
record_refusal('readout', copy, error)
if device.type == 'cuda':
torch.cuda.synchronize(device)
self.locked = False
self.fallbacks += 1

def to_host(self, destination, source):
if self.locked:
try:
destination.copy_(source, non_blocking=True)
return
except torch.cuda.OutOfMemoryError:
raise
except RuntimeError as error:
self._leave(source.device, 'to_host', error)
destination.copy_(source.to('cpu'))

def to_device(self, source, device):
if self.locked:
try:
return source.to(device, non_blocking=True)
except torch.cuda.OutOfMemoryError:
raise
except RuntimeError as error:
self._leave(torch.device(device), 'to_device', error)
return source.clone().to(device)


class HeadReadouts:
def __init__(self, rows, head_widths, dtype, tile_rows):
Expand All @@ -11,14 +80,15 @@ def __init__(self, rows, head_widths, dtype, tile_rows):
self.groups = [torch.empty((self.capacity, width), dtype=dtype, pin_memory=True) for width in head_widths]
self.tiles = [torch.empty((tile_rows, sum(head_widths)), dtype=dtype, pin_memory=True) for _ in range(2)]
self.events = [None, None]
self.copies = LockedCopies()

def supports(self, rows, head_widths, dtype, tile_rows):
return rows <= self.capacity and tuple(head_widths) == self.widths and dtype == self.dtype and tile_rows == self.tile_rows

def store(self, group, values):
if values.shape[1] != self.widths[group] or not values.is_contiguous():
raise ValueError('Head readouts must be contiguous [rows, head channels]')
self.groups[group][:len(values)].copy_(values, non_blocking=True)
self.copies.to_host(self.groups[group][:len(values)], values)

def project(self, module, output, rows):
# The caller synchronizes once after all D2H groups have been written.
Expand All @@ -33,7 +103,7 @@ def project(self, module, output, rows):
for group, width in zip(self.groups, self.widths):
tile[:, offset:offset + width].copy_(group[start:start + count])
offset += width
device_values = tile.to(output.device, non_blocking=True)
device_values = self.copies.to_device(tile, output.device)
done = torch.cuda.Event()
done.record(stream)
self.events[slot] = done
Expand Down
6 changes: 5 additions & 1 deletion freevideo_engine/adaptive.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,18 @@ def nonlocal_exhausted(failure):
Page-locked host memory counts against that budget. A 20 s request failed
with non-local usage at 13.14 of 13.24 GiB, 10.68 GiB of VRAM free and no
caching-allocator OOM; the failed allocation itself is not in the usage.
A 1920x1088 request failed with usage at 13.9 of 15.2 GiB: the allocation
that did not fit was the 2 GiB residual buffer, so the margin is the pin
reserve rather than one GiB.
"""
gpu = failure.get('gpu') if isinstance(failure.get('gpu'), dict) else {}
values = [gpu.get(key) for key in ('nonlocal_budget_bytes', 'nonlocal_usage_bytes',
'local_budget_bytes', 'local_usage_bytes')]
if not all(type(value) is int and value >= 0 for value in values) or not values[0]:
return False
budget, usage, local_budget, local_usage = values
return (usage >= budget - 2**30 and local_usage <= local_budget - 2 * 2**30
from .windows_gpu_memory import NONLOCAL_PIN_RESERVE
return (usage >= budget - NONLOCAL_PIN_RESERVE and local_usage <= local_budget - 2 * 2**30
and not gpu.get('allocator_ooms'))


Expand Down
3 changes: 2 additions & 1 deletion freevideo_engine/agent/comfy.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,8 @@ def image_start(self, action, value):
code = getattr(error, 'code', str(error))
code = code if code in IMAGE_ERRORS else 'invalid_request'
raise JobError(code, IMAGE_ERRORS[code], retryable=code in IMAGE_WAIT_ERRORS,
kind='image', report_id=getattr(error, 'report_id', None)) from None
kind='image', report_id=getattr(error, 'report_id', None),
other_usage=getattr(error, 'status', {}).get('other_usage')) from None

def image_status(self, key):
return self.image.status(key)
Expand Down
30 changes: 28 additions & 2 deletions freevideo_engine/agent/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
REQUEST_ID = ContextVar('agent_request_id', default=None)
# The work session a token request belongs to (sessions.py); jobs and productions record it.
SESSION = ContextVar('agent_session', default=None)
_UNREAD_USAGE = object()
# The operation digest a confirmation approves: method, route, path values and body without ref.
CONFIRM = ContextVar('agent_confirm', default=None)
# code: HTTP status, retryable, public message, concrete recovery action
Expand Down Expand Up @@ -128,6 +129,8 @@

# Static bilingual image reasons; backend exception strings never enter the API.
IMAGE_REASONS = {
'small_memory': ('This Mac has less than 16 GB of memory and cannot generate pictures.',
'这台 Mac 的内存不足 16 GB,无法生成图片。'),
'no_memory': ('Not enough GPU memory, even at the smallest size. Close other apps using the GPU, then try again.',
'显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。'),
'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'),
Expand Down Expand Up @@ -164,6 +167,7 @@ def image_failure(code, report_id=None):
# Recovery is a function of the committed task and phase, not just an error code.
IMAGE_ERRORS = {
**LORA_MESSAGE_EN,
'small_memory': IMAGE_REASONS['small_memory'][0],
'invalid_request': 'The picture request contains invalid fields.',
'invalid_prompt': 'The picture prompt must contain text and be at most 2000 characters.',
'invalid_dimensions': 'Picture width and height must be integers from 256 to 2048.',
Expand Down Expand Up @@ -413,7 +417,26 @@ def failure_reason_en(error):


def reasons(error):
return dict(error_zh=failure_reason(error), error_en=failure_reason_en(error))
from ..other_usage import fact, join
result = dict(error_zh=failure_reason(error), error_en=failure_reason_en(error))
for key, zh in (('error_zh', True), ('error_en', False)):
suffix = fact(error.get('other_usage'), zh=zh)
if suffix and suffix not in result[key]:
result[key] = join(result[key], suffix, zh=zh)
return result


def capture_other_usage(error):
from ..other_usage import at_failure, fact, join, MEMORY_ERRORS
if error and error.get('code') in MEMORY_ERRORS and 'other_usage' not in error:
error = dict(error, other_usage=at_failure(error['code']))
if error and error.get('other_usage') and error.get('reason'):
error = dict(error, reason=dict(error['reason']))
for lang in ('en', 'zh'):
suffix = fact(error['other_usage'], zh=lang == 'zh')
if suffix and suffix not in error['reason'].get(lang, ''):
error['reason'][lang] = join(error['reason'].get(lang, ''), suffix, zh=lang == 'zh')
return error


def with_reason(error):
Expand All @@ -422,7 +445,7 @@ def with_reason(error):

def build(code, *, details=None, request=None, validation=False, message=None, next_action=None,
action_required=True, retry_after_seconds=None, owner=None, kind=None, stage='request', resumable=True,
report_id=None, memory=None):
report_id=None, memory=None, other_usage=_UNREAD_USAGE):
code = code if code in ERRORS else 'internal_error'
_, retryable, default_message, _ = ERRORS[code]
action = recovery(code, kind=kind, stage=stage, resumable=resumable)
Expand All @@ -438,6 +461,9 @@ def build(code, *, details=None, request=None, validation=False, message=None, n
result.update(image_failure(code, report_id))
if code == 'no_memory' and memory == 'host':
result['memory'] = 'host'
if other_usage is not _UNREAD_USAGE:
result['other_usage'] = other_usage
result = capture_other_usage(result)
if action_required:
result['next'] = next_action if validation and next_action else action
if code == 'busy':
Expand Down
16 changes: 11 additions & 5 deletions freevideo_engine/agent/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ def view(self):
return errors.build(self.code, details=self.details.get('details'),
owner=self.details.get('owner'),
retry_after_seconds=self.details.get('retry_after_seconds'),
kind=self.details.get('kind'), report_id=self.details.get('report_id'))
kind=self.details.get('kind'), report_id=self.details.get('report_id'),
**({'other_usage': self.details['other_usage']} if 'other_usage' in self.details else {}))


class ManagedByProduction(JobError):
Expand Down Expand Up @@ -681,16 +682,19 @@ def touch(self, row, **changes):

def finish(self, row, status, *, result=None, error=None, save=True):
if error is not None:
error = errors.capture_other_usage(error)
code = error.get('code', 'internal_error')
report_id = error.get('report_id')
if code == 'invalid_json' and row['kind'] in ('vlm', 'enhance'):
code = 'vlm_invalid_json'
reason_zh, reason_en = errors.failure_reason(dict(error, code=code)), errors.failure_reason_en(dict(error, code=code))
failure_reasons = errors.reasons(dict(error, code=code))
reason_zh, reason_en = failure_reasons['error_zh'], failure_reasons['error_en']
if code != error.get('code') or not error.get('request_id') or row['kind'] == 'image':
error = errors.build(code, details=error.get('details'),
request=row.get('request_id'), action_required=code != 'cancelled',
kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from'),
report_id=error.get('report_id'), memory=error.get('memory'))
report_id=error.get('report_id'), memory=error.get('memory'),
other_usage=error.get('other_usage'))
if row['kind'] == 'image':
reason_zh, reason_en = error['error_zh'], error['error_en']
if error is not None:
Expand Down Expand Up @@ -1235,7 +1239,8 @@ def view(self, row, *, detail=True):
if row['kind'] == 'image':
result['error'] = errors.build(result['error']['code'], kind='image', stage='terminal',
details=result['error'].get('details'), request=row.get('request_id'),
action_required=result['error']['code'] != 'cancelled', report_id=row['error'].get('report_id'))
action_required=result['error']['code'] != 'cancelled', report_id=row['error'].get('report_id'),
other_usage=row['error'].get('other_usage'))
else:
result['error'] = dict(result['error'], **errors.reasons(row['error']))
report_id = row['error'].get('report_id')
Expand Down Expand Up @@ -2098,7 +2103,8 @@ def poll_image(self, row):
code = failure.get('code') if isinstance(failure, dict) else failure
code = code or 'generation_failed'
if not self.wait_image(row, code):
self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory')))
self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory'),
other_usage=state.get('other_usage')))

# Local VLM -----------------------------------------------------------------------------------------
def start_vlm(self, row):
Expand Down
3 changes: 3 additions & 0 deletions freevideo_engine/agent/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,9 @@ def openapi():
'description': 'Image diagnostic report ID, or null when no report exists.'},
request_id=string(), docs=string(), retry_after_seconds=number(0), card={'type': 'object'}, job=JOB,
project={'type': 'object'}, current_revision=number(1, integer=True),
other_usage=nullable(obj(dict(vram_gib=number(0), ram_gib=number(0),
kinds={'type': 'array', 'items': enum(('gpu', 'ram'))}, signature=string()),
('vram_gib', 'ram_gib', 'kinds', 'signature'))),
error_zh=string(), error_en=string()),
('code', 'message', 'details', 'retryable', 'request_id', 'docs'))}))

Expand Down
Loading