Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b4b2ab3
Keep the image plan's working margin out of the commit headroom check
KBRASK Oct 25, 2026
548a933
Carry the host-memory shortage from the image worker to the page
KBRASK Oct 25, 2026
ee61e54
Leave the rewrite's subject definitions out of the prompt box
KBRASK Oct 26, 2026
6c6d7b5
Launcher: explain an NVIDIA driver that is installed but not running
KBRASK Oct 26, 2026
c33fe02
Driver-not-running card: quote the English button name with straight …
KBRASK Oct 27, 2026
450d64c
Linux: tell the user when no file manager can open a folder, honor th…
KBRASK Oct 26, 2026
eb74bbd
Open folder: show a note with the images folder when the folder canno…
KBRASK Oct 27, 2026
13eb90a
Characters: the small round picture is the face close-up, else the fr…
KBRASK Oct 27, 2026
9846b20
Characters: crop the small round picture to the head
KBRASK Oct 27, 2026
b341d12
Prompt enhancement: show a rewrite as the scene and its sound
KBRASK Oct 27, 2026
ce8de6f
Interface: keep curly single quotes at their Latin width
KBRASK Oct 27, 2026
99ee405
Explain the device limit when the part length cannot be edited
KBRASK Oct 27, 2026
3df10a7
Keep the chosen state while a confirmation setting saves; put the min…
KBRASK Oct 29, 2026
41334c7
Show the reason for each download source when all sources fail
KBRASK Oct 29, 2026
2d84dcd
Show what the server holds after a failed confirmation save; keep the…
KBRASK Oct 29, 2026
06580fc
Mac setup probes the install folder for links and renames
KBRASK Oct 26, 2026
6af06c0
Use the reviewed wording for download source reasons on the error card
KBRASK Oct 29, 2026
79edaff
Redact Windows paths with spaces and shares in download source reasons
KBRASK Oct 29, 2026
11032a2
Keep download source reasons and times whole when the card wraps
KBRASK Oct 29, 2026
f74e1fe
Recent videos open at every window width
KBRASK Oct 29, 2026
36b0fb1
Show the connection-lost sentence when prompt enhancement loses the s…
KBRASK Oct 29, 2026
17b5f32
Prompt enhancement: show the extension message when the failed reques…
KBRASK Oct 29, 2026
80362d7
Mac: input-video encoding fits tighter unified-memory allowances
KBRASK Oct 29, 2026
70e8210
Keep character names in rewritten prompts
KBRASK Oct 26, 2026
eaab220
Shorten the part length advice and keep screenshots optional in its b…
KBRASK Oct 29, 2026
fa2ddba
Carry the host-memory marker to every image failure card with the fin…
KBRASK Oct 29, 2026
f7f838b
Word a part length that is too short or too long in the length panel
KBRASK Oct 27, 2026
6e6248d
Studio: show the starter prompt as numbered shots and a sound paragraph
KBRASK Oct 29, 2026
1467a9b
Let the user choose in Settings whether an agent asks before a film o…
KBRASK Oct 29, 2026
042ee46
Studio: show a reused or older prompt in sections as numbered shots a…
KBRASK Oct 29, 2026
b6f328f
Treat exhausted Windows virtual memory as out of memory for image gen…
KBRASK Oct 25, 2026
7c99a83
Show the detected graphics card on the missing-driver card
KBRASK Oct 29, 2026
971102d
Release notes for 0.4.26
KBRASK Oct 29, 2026
ca68852
Page check: list the guide, tutorial, empty-prompt and LoRA swap scripts
KBRASK Oct 29, 2026
2ac422d
Agent MCP: return plain-text tool errors to the Agent as readable mes…
KBRASK Oct 29, 2026
9a47280
Pin the native attention kernel order for the text refiner
KBRASK Oct 27, 2026
643e593
Editor: say when the last edit cannot be found instead of opening a b…
KBRASK Oct 29, 2026
6af39ac
Prompt enhancement: an edited prompt during a rewrite is a note, not …
KBRASK Oct 24, 2026
de0167d
Release notes for 0.4.26: capitalize the merged creation panel line
KBRASK Oct 29, 2026
b6cc31d
Editor: point at the name menu after a missing last edit only when an…
KBRASK Oct 29, 2026
e691c15
Plan without unchecked attention kernels that cannot run on this GPU
KBRASK Oct 29, 2026
17159b9
Release notes for 0.4.26: no unrunnable attention method on first gen…
KBRASK Oct 29, 2026
b7ec874
Queue polling: count a wrapped fetch alone as an outage, not as a blo…
KBRASK Oct 29, 2026
9e32331
Queue polling: a failed loop submission and a failing history read ar…
KBRASK Oct 29, 2026
b7fc7f3
Release notes for 0.4.26: queue no longer reports extension interfere…
KBRASK Oct 29, 2026
8bc73df
Add ModelScope source for encoder
KBRASK Oct 26, 2026
827b6ee
Release notes: text encoder download source
KBRASK Oct 30, 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: 2 additions & 1 deletion freevideo_engine/agent/cast.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ def view(card):

def thumbnail(card):
images = card['images']
chosen = next((row for row in images if row['label'] == 'front'), images[0] if images else None)
# The close-up when the card has one, else the front picture; shown small, a full-length picture is only a coat.
chosen = next((row for label in ('face', 'front') for row in images if row['label'] == label), images[0] if images else None)
return media_view(chosen['media']) if chosen else None


Expand Down
14 changes: 10 additions & 4 deletions freevideo_engine/agent/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,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):
report_id=None, memory=None):
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 @@ -422,6 +422,8 @@ def build(code, *, details=None, request=None, validation=False, message=None, n
docs='/freevideo/agent/v1/guide#errors')
if kind == 'image':
result.update(image_failure(code, report_id))
if code == 'no_memory' and memory == 'host':
result['memory'] = 'host'
if action_required:
result['next'] = next_action if validation and next_action else action
if code == 'busy':
Expand All @@ -441,9 +443,13 @@ def validation(errors, code='invalid_request'):


def text(error):
"""One line for the Agent; a plain-text error (a failed white-model scene) is its own message."""
if not isinstance(error, dict):
return str(error).strip() or 'failed: The request did not finish.'
reason = error.get('reason') or {}
message = reason.get('en') or error['message']
diagnostic = image_failure(error['code'], error.get('report_id'))['report_id'] if reason else None
return '%s: %s%s%s' % (error['code'], message,
code = error.get('code') or 'failed'
message = reason.get('en') or error.get('message') or ''
diagnostic = image_failure(code, error.get('report_id'))['report_id'] if reason else None
return '%s: %s%s%s' % (code, message,
' Diagnostic report: /freevideo/image-gen/report/' + diagnostic if diagnostic else '',
' Next: ' + error['next'] if error.get('next') else '')
4 changes: 2 additions & 2 deletions freevideo_engine/agent/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ def finish(self, row, status, *, result=None, error=None, save=True):
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'))
report_id=error.get('report_id'), memory=error.get('memory'))
if row['kind'] == 'image':
reason_zh, reason_en = error['error_zh'], error['error_en']
if error is not None:
Expand Down Expand Up @@ -1979,7 +1979,7 @@ 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')))
self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id'), memory=state.get('memory')))

# Local VLM -----------------------------------------------------------------------------------------
def start_vlm(self, row):
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 @@ -741,7 +741,7 @@ def image_document():

def whitebox_schema(name):
if name == 'submit':
return obj(dict(code=string(65536, description='JavaScript that defines build(THREE) and returns {scene, camera, update(t)}.'),
return obj(dict(code=string(65536, description='An ES module with export function build(THREE) that returns {scene, camera, update(t)}.'),
summary=string(500, description='One sentence on what the scene shows, for the confirm card.')), ('code',))
return obj({'wait': WAIT})

Expand Down
251 changes: 245 additions & 6 deletions freevideo_engine/backends/mps_vae_encode.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,272 @@
"""Bound the pending Metal work of the original FP32 input-video encoder."""
"""Bound the pending Metal work and the live tensors of the original FP32 input-video encoder."""
from contextlib import contextmanager


def _lean_conv_ok(conv):
return (getattr(conv, 'spatial_padding', None) == 1 and getattr(conv, 'temporal_padding', None) in (0, 2)
and getattr(conv, 'spatial_padding_mode', None) == 'reflect' and conv.groups == 1
and tuple(conv.padding) == (0, 0, 0) and conv.padding_mode == 'zeros')


def _is_resnet(module):
names = ('norm1', 'conv1', 'norm2', 'conv2', 'conv_shortcut')
return (type(module).__name__ == 'MiniMaxH3VideoResnetBlock3d' and all(hasattr(module, n) for n in names)
and _lean_conv_ok(module.conv1) and _lean_conv_ok(module.conv2)
and type(module.norm1).__name__ == 'MiniMaxH3VideoGroupNorm'
and type(module.norm2).__name__ == 'MiniMaxH3VideoGroupNorm')


class _Hold:
"""The only reference to a tile-sized activation between two encoder stages.

A caller's own variable or argument would keep the previous activation alive
through the whole stage; the stage takes it out of here and drops it.
"""
__slots__ = ('value',)

def __init__(self, value):
self.value = value

def take(self):
value, self.value = self.value, None
return value


def _norm_silu(norm, box):
"""silu(norm(x)) as a (B, T, C, H, W) tensor viewed back as (B, C, T, H, W).

`box` is a one-element list holding x; its copy is released as soon as the
folded input exists, unless the caller keeps another reference.

The pinned group norm folds the frame axis into the batch; the same call is
made on the same values, only the intermediate copies are released early and
the elementwise SiLU runs in place.
"""
import torch
x = box.pop()
batch, channels, frames, height, width = x.shape
folded = x.permute(0, 2, 1, 3, 4).contiguous().view(batch * frames, channels, 1, height, width)
del x
normed = torch.nn.GroupNorm.forward(norm, folded)
del folded
normed = normed.view(batch, frames, channels, height, width)
return torch.nn.functional.silu(normed, inplace=True).permute(0, 2, 1, 3, 4)


def _padded(source, temporal):
"""`source` with a 1-pixel reflect border and `temporal` leading zero frames in one buffer.

Same values as reflect pad followed by a constant temporal pad, written
without the intermediate spatially padded copy.
"""
import torch
batch, channels, frames, height, width = source.shape
out = torch.empty((batch, channels, frames + temporal, height + 2, width + 2),
dtype=source.dtype, device=source.device)
if temporal:
out[:, :, :temporal] = 0
body = out[:, :, temporal:]
body[:, :, :, 1:-1, 1:-1] = source
body[:, :, :, 0, 1:-1] = source[:, :, :, 1, :]
body[:, :, :, -1, 1:-1] = source[:, :, :, -2, :]
body[:, :, :, :, 0] = body[:, :, :, :, 2]
body[:, :, :, :, -1] = body[:, :, :, :, -3]
return out


def _downsample_ok(module):
conv = getattr(module, 'conv', None)
return (type(module).__name__ == 'MiniMaxH3VideoDownsample3d' and conv is not None
and getattr(module, 'spatial_stride', None) == 2 and getattr(module, 'spatial_padding_mode', None) == 'reflect'
and getattr(conv, 'spatial_padding', None) == 0 and getattr(conv, 'temporal_padding', None) == 2
and conv.groups == 1 and tuple(conv.padding) == (0, 0, 0) and conv.padding_mode == 'zeros')


def _lean_downsample(module, fence):
"""Reflect pad of one bottom/right pixel and the causal temporal zero pad in one buffer."""
import torch
def forward(hidden_states):
if isinstance(hidden_states, _Hold):
hidden_states = hidden_states.take()
batch, channels, frames, height, width = hidden_states.shape
padded = torch.empty((batch, channels, frames + 2, height + 1, width + 1),
dtype=hidden_states.dtype, device=hidden_states.device)
padded[:, :, :2] = 0
body = padded[:, :, 2:]
body[:, :, :, :-1, :-1] = hidden_states
body[:, :, :, -1, :-1] = hidden_states[:, :, :, -2, :]
del hidden_states
body[:, :, :, :, -1] = body[:, :, :, :, -3]
hidden = torch.nn.Conv3d.forward(module.conv, padded)
del padded, body
fence()
return hidden
return forward


def _lean_resnet(block, fence, stats):
"""The original resnet forward with at most three activation-sized tensors alive.

Every convolution still receives a contiguous, identically padded tensor and
the same weights; the residual sum is the same two operands. With
`stats['residual_offload']` the block input waits in host memory while the
block runs (an exact copy), which keeps one more tile-sized tensor off the
device at the cost of a copy there and back.
"""
import torch
def forward(hidden_states):
if isinstance(hidden_states, _Hold):
hidden_states = hidden_states.take()
device = hidden_states.device
offload = bool(stats.get('residual_offload')) and block.conv_shortcut is None
residual = hidden_states.to('cpu', copy=True) if offload else hidden_states
box = [hidden_states]
del hidden_states
activated = _norm_silu(block.norm1, box)
padded = _padded(activated, block.conv1.temporal_padding)
del activated
hidden = torch.nn.Conv3d.forward(block.conv1, padded)
del padded
fence()
box = [hidden]
del hidden
activated = _norm_silu(block.norm2, box)
padded = _padded(activated, block.conv2.temporal_padding)
del activated
hidden = torch.nn.Conv3d.forward(block.conv2, padded)
del padded
fence()
if block.conv_shortcut is not None:
residual = block.conv_shortcut(residual)
return hidden.add_(residual.to(device) if offload else residual)
return forward


def _lean_encoder_ok(model):
encoder = getattr(model, 'encoder', None)
names = ('conv_in', 'down_blocks', 'norm_out', 'conv_out')
if encoder is None or not all(hasattr(encoder, n) for n in names) or getattr(model, 'quant_conv', None) is None:
return False
blocks = list(encoder.down_blocks)
return bool(blocks) and all(hasattr(b, 'resnets') and hasattr(b, 'downsamplers') and len(b.resnets)
and all(_is_resnet(r) for r in b.resnets)
and all(_downsample_ok(d) for d in (b.downsamplers or ())) for b in blocks)


def _lean_block(block):
def forward(held):
for resnet in block.resnets:
held.value = resnet(held)
if block.downsamplers is not None:
for downsampler in block.downsamplers:
held.value = downsampler(held)
return held
return forward


def _lean_encoder(encoder, torch):
def forward(hidden_states):
held = _Hold(encoder.conv_in(hidden_states))
for block in encoder.down_blocks:
block(held)
hidden_states = held.take()
hidden_states = torch.nn.functional.silu(encoder.norm_out(hidden_states))
return encoder.conv_out(hidden_states)
return forward


def _residency(model, device, torch):
"""Hooks that hold each encoder stage's weights on `device` only while it runs.

The weights are the same FP32 values read from the checkpoint; only the
stage being computed is resident on the device, so the large late-stage
weights are not alive while the early stages hold the largest activations.
Returns (hooks, host copies still on the device, uploaded byte counter).
"""
encoder = model.encoder
units = [encoder.conv_in, *encoder.down_blocks, encoder.norm_out, encoder.conv_out, model.quant_conv]
covered = {id(p) for unit in units for p in unit.parameters()}
if any(id(p) not in covered for p in model.parameters()):
return None
host, uploaded, handles = {}, [0], []
def upload(unit, inputs):
for parameter in unit.parameters():
# A failed run leaves its stage here with the device copy in place;
# the host copy kept from the first upload stays the source.
host.setdefault(parameter, parameter.data)
parameter.data = host[parameter].to(device)
uploaded[0] += parameter.numel() * parameter.element_size()
def release(unit, inputs, output):
for parameter in unit.parameters():
parameter.data = host.pop(parameter)
for unit in units:
handles.append(unit.register_forward_pre_hook(upload))
handles.append(unit.register_forward_hook(release))
return handles, host, uploaded


@contextmanager
def bounded_encoder(model):
def bounded_encoder(model, device=None):
"""Finish each convolution before submitting the next encoder operation.

MPS can retain large temporary allocations for pending 3D convolutions even
after their Python tensors are gone. Fencing the original operators bounds
that lifetime without changing weights, precision, tiles or posterior noise.
Hooks belong to this VAE instance and are always removed.
The residual blocks additionally build each padded convolution input in one
buffer, run the SiLU in place, add the residual in place and let each stage
release the previous activation: the same values, with fewer tile-sized
tensors alive at the peak. With `device`, the model's weights stay on the
host (the checkpoint mapping) and each stage uploads its own for its run.
Hooks and instance forwards belong to this VAE instance and are always
removed.
"""
import torch
if torch.is_grad_enabled():
raise RuntimeError('Bounded MPS input encoding requires inference')
modules = [module for module in model.modules() if isinstance(module, torch.nn.Conv3d)]
if not modules:
raise ValueError('Expected the H3 input encoder convolution modules')
handles = []
handles, patched, host, staged, uploaded = [], [], {}, None, [0]
stats = dict(policy='Complete original FP32 convolutions and release unused MPS allocations',
convolution_completions=0, convolution_modules=len(modules))
def complete(module, inputs, output):
convolution_completions=0, convolution_modules=len(modules), lean_resnet_blocks=0,
staged_weight_bytes=0)
def fence():
torch.mps.synchronize()
torch.mps.empty_cache()
stats['convolution_completions'] += 1
def complete(module, inputs, output):
fence()
try:
for module in modules:
handles.append(module.register_forward_hook(complete))
if _lean_encoder_ok(model):
for block in model.encoder.down_blocks:
for resnet in block.resnets:
resnet.forward = _lean_resnet(resnet, fence, stats)
patched.append(resnet)
for downsampler in block.downsamplers or ():
downsampler.forward = _lean_downsample(downsampler, fence)
patched.append(downsampler)
block.forward = _lean_block(block)
patched.append(block)
model.encoder.forward = _lean_encoder(model.encoder, torch)
patched.append(model.encoder)
stats['lean_resnet_blocks'] = sum(len(b.resnets) for b in model.encoder.down_blocks)
if device is not None:
staged = _residency(model, device, torch)
if staged is not None:
hooks, host, uploaded = staged
handles.extend(hooks)
if device is not None and staged is None:
# Not the pinned structure: whole model on the device, fences only.
model.to(device)
yield stats
finally:
if staged is not None:
stats['staged_weight_bytes'] = uploaded[0]
for handle in handles:
handle.remove()
for parameter, data in host.items():
parameter.data = data
for module in patched:
module.__dict__.pop('forward', None)
3 changes: 2 additions & 1 deletion freevideo_engine/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -1777,7 +1777,8 @@ def attempt(source, route, retry_mode):
return network.download_command(self.network, 'github', attempt, self.env, category='python',
extra_sources=['user-python'] if custom else [], notice=notice)
except network.PackageSourcesError as error:
failure = RuntimeError('Python download failed on every route; see retained logs')
failure = RuntimeError('Python download failed on every route; see retained logs'
+ network.sources_text(error.attempts))
failure.package_attempts = error.attempts
raise failure from error

Expand Down
4 changes: 2 additions & 2 deletions freevideo_engine/comfy_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ def git_run(label, command, env):
[uv, 'pip', 'install', '--python', python, *NODE_PACKAGES], environment, 'Install ComfyUI packages'), env)
except network.PackageSourcesError as error:
error.log_files = list(package_logs)
error.args = (str(error) + '. Attempt logs: ' + ', '.join(package_logs),)
error.args = (str(error) + '\nAttempt logs: ' + ', '.join(package_logs),)
raise
save(receipt, identity)
elif repair or not recorded:
Expand Down Expand Up @@ -233,7 +233,7 @@ def package_install(family, command, label):
return network.package_command(plan, family, command, env)
except network.PackageSourcesError as error:
error.log_files = list(package_logs)
error.args = (str(error) + '. Attempt logs: ' + ', '.join(package_logs),)
error.args = (str(error) + '\nAttempt logs: ' + ', '.join(package_logs),)
raise

prepared = False
Expand Down
7 changes: 7 additions & 0 deletions freevideo_engine/dependencies.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@
"encoder_revision": "d6aa5dfc669a749dc9364a36f3b6d4d1545a53e8",
"encoder_file": "text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"encoder_sha256": "35a88d51044231fe332301d7a62aa81e3f2cba62febeb446e2c1e3e0ef76f2c6",
"encoder_modelscope": {
"repo": "LAAASSS/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"revision": "526349f8fbb32e5fcb0cb5e9235b30f45f1e1482",
"hf_revision": "d6aa5dfc669a749dc9364a36f3b6d4d1545a53e8",
"file": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"probe_file": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
},
"edge_modelscope": {
"repo": "OpenVDN/vdn-minimax-h3-edge",
"revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a",
Expand Down
Loading