Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
24ba05e
Release 0.5.2: update notes
KBRASK Nov 12, 2026
edf2383
Mac: record why a copied FreeVideo app fails verification, for the di…
KBRASK Nov 12, 2026
e6ac908
Export dialog: open without a window resize hook or layout, so the ed…
KBRASK Nov 12, 2026
1604e8d
Autopilot: a message that was not sent shows its reason on its own li…
KBRASK Nov 12, 2026
891b5fc
Offline import: complete model files left short by an interrupted write
KBRASK Nov 12, 2026
3c7ef4f
Failure advice: offline installations are told to import the offline …
KBRASK Nov 12, 2026
2eba98a
Media panel: show the could-not-read line once, below all three rows
KBRASK Nov 12, 2026
ae3265b
Nodes view: fit the graph below the floating view bar so it never cov…
KBRASK Nov 12, 2026
62089e3
Nodes view: Chinese names for the voice reference, dialogue tail and …
KBRASK Nov 12, 2026
fc7c24c
Connection card: the arrow wraps with its words, a failed lookup show…
KBRASK Nov 12, 2026
62089f4
Explain missing or damaged model files on the failure card, and name …
KBRASK Nov 12, 2026
09ddde4
Settings: plan sizes under 1 GiB show MiB or KiB instead of 0.0 GiB, …
KBRASK Nov 12, 2026
7b77d5a
Settings: unavailable buttons share one gray look and no longer react…
KBRASK Nov 12, 2026
3b06aa7
Add Open settings to failures that point to page settings repair
KBRASK Nov 12, 2026
9f54b23
Studio queue: a read answered with a web page instead of data is hand…
KBRASK Nov 12, 2026
0a25024
Keep model row phrases whole when the setup plan wraps
KBRASK Nov 12, 2026
78a4596
Keep the setup agreement's closing phrase whole when it wraps
KBRASK Nov 12, 2026
3c0c41a
Take reference picture limits from each image model's engine row
KBRASK Nov 12, 2026
c5f0974
Editor library: thumbnails carry the API prefix once
KBRASK Nov 12, 2026
bba9bce
Keep running when Windows application control refuses a SciPy file, a…
KBRASK Nov 12, 2026
e317c51
An engine setup rebuilds a Diffusers folder that Git cannot read inst…
KBRASK Nov 12, 2026
8dfed0b
Tutorial book: add beat-snap and system-status chapters
KBRASK Nov 12, 2026
62ec115
H3 Image takes up to 16 reference pictures, refuses ones that cannot …
KBRASK Nov 12, 2026
5edeea1
A refused whole-sequence buffer no longer takes another head-group re…
KBRASK Nov 13, 2026
23ac5be
Launcher report form names the real failure, not a ready download-too…
KBRASK Nov 13, 2026
2648b2e
The GPU precheck before an int8 switch no longer requires model folde…
KBRASK Nov 13, 2026
8bdfcbe
Keep slow Python runtime downloads patient when a mirror refuses acce…
KBRASK Nov 13, 2026
1d14240
Image generation: save results as high-quality JPEG and remove the pa…
KBRASK Nov 13, 2026
53bece9
Waits say what is happening: prompt rewriting, size range, starting a…
KBRASK Nov 13, 2026
9bd2d00
A damaged reserve setting is kept aside, a refused save is explained …
KBRASK Nov 13, 2026
b7e4be3
A finished film also opens as an editing project, one part per shot
KBRASK Nov 13, 2026
2853f57
A screenplay carries its outline and the blocking of each scene
KBRASK Nov 13, 2026
cc2161f
Imported clips can be removed from the editor library, with a second …
KBRASK Nov 13, 2026
0d32984
Deleting an imported clip fails closed when an edit cannot be read
KBRASK Nov 13, 2026
70b1b85
Deleting an imported clip also asks when an edit has it only as an ea…
KBRASK Nov 13, 2026
a379f82
A film that uses the built-in director has its takes chosen and joine…
KBRASK Nov 13, 2026
30670d5
Tutorial: reference pictures, short-drama outline and staging, the ed…
KBRASK Nov 13, 2026
7e33df7
Chat: a finished short film comes as a finished-video card with Open …
KBRASK Nov 13, 2026
b9b6a67
H3 Image: the same prompt and references are encoded once
KBRASK Nov 13, 2026
55a9011
H3 Image: the encoder, DiT stack and VAE stay resident between pictur…
KBRASK Nov 13, 2026
8a17cbd
H3 Image: kept models give the card back before the reference encoder…
KBRASK Nov 13, 2026
94a90be
Node tests follow the offline repair import, the Detect & review repa…
KBRASK Nov 13, 2026
88c4f75
Release 0.5.3: update notes
KBRASK Nov 13, 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
18 changes: 15 additions & 3 deletions freevideo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,18 @@ try {
$env:UV_NO_CONFIG = '1'
$installed = $false
$pythonRoutes = @($routes | ForEach-Object { "$_/astral-sh/python-build-standalone/releases/download" })
if (-not $officialOnly) {
# The GitHub mirror refuses the Python runtime files: the public npmmirror copy
# (same SHA-256 that uv pins) is tried before it. Keep in sync with network.py.
$pythonRoutes = @($pythonRoutes[0]) + @('https://registry.npmmirror.com/-/binary/python-build-standalone') + @($pythonRoutes | Select-Object -Skip 1)
}
if (-not $officialOnly -and $freevideoSavedEnvironment['UV_PYTHON_INSTALL_MIRROR']) {
$pythonRoutes = @($freevideoSavedEnvironment['UV_PYTHON_INSTALL_MIRROR']) + $pythonRoutes
}
$attempt = 0
$allNetworkFailures = $true
$otherFailures = $false
$slowRoutes = @{}
$deniedRoutes = @{}
# Keep these defaults and the notice in sync with network.py (CPU tests).
$pythonRetryProfiles = @(
@{Mode='fast'; UV_HTTP_TIMEOUT='20'; UV_HTTP_RETRIES='1'},
Expand All @@ -109,7 +116,9 @@ try {
foreach ($profile in $pythonRetryProfiles) {
if ($installed) { break }
if ($profile.Mode -eq 'patient') {
if (-not $allNetworkFailures) { break }
if ($otherFailures) { break }
$retryRoutes = @($slowRoutes.Keys | Where-Object { -not $deniedRoutes.ContainsKey($_) })
if ($retryRoutes.Count -eq 0) { break }
Write-Host 'The network is slow. Downloading again; this may take a while.'
$patientDownloadNotice = $true
if ($env:FREEVIDEO_UI_EVENTS -eq '1') {
Expand All @@ -122,6 +131,7 @@ try {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
foreach ($route in $pythonRoutes) {
if ($profile.Mode -eq 'patient' -and $deniedRoutes.ContainsKey($route)) { continue }
foreach ($proxyRoute in $proxyRoutes) {
if ($installed) { break }
$attempt++
Expand All @@ -135,7 +145,9 @@ try {
-LogPath $bootstrapLog -Label $pythonLabel `
-Direct:($proxyRoute -eq 'direct') -OutputTail ([ref]$pythonOutput)
if ($pythonExit -eq 0) { $installed = $true; break }
if (-not (Test-FreeVideoSlowDownloadFailure $pythonOutput)) { $allNetworkFailures = $false }
if (Test-FreeVideoAccessDenied $pythonOutput) { $deniedRoutes[$route] = $true }
elseif (Test-FreeVideoSlowDownloadFailure $pythonOutput) { $slowRoutes[$route] = $true }
else { $otherFailures = $true }
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" }
Expand Down
38 changes: 35 additions & 3 deletions freevideo_engine/adaptive.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,17 +424,40 @@ def nonlocal_exhausted(failure):
COMPUTE_DEFAULTS = dict(window_batch=1, head_parallelism=1)


def compute_recovery(engine):
# One activation of the whole sequence is rows x 5376 channels x 2 bytes (bf16).
# A refused allocation of at least the video rows' worth does not shrink with
# the head group; the group only sizes the per-head workspaces.
SEQUENCE_ROW_BYTES = 5376 * 2
HEAD_GROUP_KEYS = ('head_chunk', 'head_parallelism')


def sequence_sized_failure(failure, canvas):
"""The refused allocation is at least one whole-sequence activation."""
rows = (canvas or {}).get('video_tokens')
if type(rows) is not int or rows <= 0:
return False
exceptions = failure.get('exception')
for item in exceptions if isinstance(exceptions, list) else []:
size = item.get('requested_allocation_bytes') if isinstance(item, dict) else None
if type(size) is int and size >= rows * SEQUENCE_ROW_BYTES:
return True
return False


def compute_recovery(engine, fixed_heads=False):
"""The next smaller compute partition for the bounded inference path, or None.

Integers only shrink and flags only switch on, so recovery never widens a
partition that already failed.
partition that already failed. ``fixed_heads`` leaves the head group alone
and offers only the remaining partitions and flags.
"""
if engine.get('inference_kernels') is not True or type(engine.get('head_chunk')) is not int or engine['head_chunk'] <= 0:
return None
for rung in COMPUTE_RECOVERY:
updates = {}
for name, target in rung.items():
if fixed_heads and name in HEAD_GROUP_KEYS:
continue
current = engine.get(name, COMPUTE_DEFAULTS.get(name))
if target == 'half':
# Once: a group of 2 or fewer heads goes on to the bounded rung.
Expand Down Expand Up @@ -539,6 +562,7 @@ def change(section, name, new):
options[name] = new
host_pins = failure['kind'] == 'gpu_oom' and nonlocal_exhausted(failure)
compute = None
heads_fixed = False
if failure['kind'] == 'gpu_oom':
if phase == 'sample' and engine.get('step_cache') == 'fbcache' and not host_pins:
# Its two packed residuals are the newest GPU allocation in the
Expand Down Expand Up @@ -602,7 +626,12 @@ def change(section, name, new):
and engine.get('window_batch', 1) == 1):
change('engine', 'residual_offload', True)
if not changes and phase == 'sample':
compute = compute_recovery(engine)
# A whole-sequence buffer that already failed with a group of two or
# fewer heads does not shrink with the group: the retry would only
# repeat the completed steps and fail in the same place.
heads_fixed = (type(engine.get('head_chunk')) is int and engine['head_chunk'] <= 2
and sequence_sized_failure(failure, canvas))
compute = compute_recovery(engine, fixed_heads=heads_fixed)
for name, new in (compute or {}).items():
change('engine', name, new)
# Offloading more GPU weights must not turn the next attempt
Expand Down Expand Up @@ -663,6 +692,9 @@ def change(section, name, new):
changes={}, numerical_class='placement-only', measurement=failure,
ram_recovery=ram_recovery)
if not changes:
if heads_fixed:
return None, {'reason': 'The refused allocation is a whole-sequence buffer that a smaller head group '
'does not shrink, and no other placement or partition is left.', 'changes': {}}
return None, {'reason': 'No further placement or compute-partition recovery is available.', 'changes': {}}
if 'policy' in value:
value['policy'].update(engine=dict(engine), decoder=dict(decoder))
Expand Down
3 changes: 2 additions & 1 deletion freevideo_engine/agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ def source(value):
path = path.replace('{id}', args.id)
result = client.get(path, **value) if method == 'GET' else client.post(path, value)
elif args.command == 'upload':
result = client.upload(args.path)
result = client.upload(args.path, stage=lambda name: print(
'Uploading…' if name == 'sending' else 'Checking the file…', file=sys.stderr, flush=True))
elif args.command == 'imports':
result = (client.import_media(args.path, **({'ref': args.ref} if hasattr(args, 'ref') else {}))
if args.operation == 'add' else client.get('/imports'))
Expand Down
18 changes: 13 additions & 5 deletions freevideo_engine/agent/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,11 @@ def asset(self, identity, target, *, thumbnail=False):
path = '/freevideo/agent/v1/library/%s/%s' % (identity, 'thumbnail' if thumbnail else 'file')
return self.download({'url': path}, target)

def upload(self, path):
"""Read on the client computer and upload content; never send a local path."""
return self._upload(path, '/media')
def upload(self, path, *, stage=None):
"""Read on the client computer and upload content; never send a local path.
``stage('sending')`` and ``stage('checking')`` say when the content goes out
and when the server is decoding it, which can take long for a large video."""
return self._upload(path, '/media', stage=stage)

def import_media(self, path, *, ref=None):
"""Import a client file into the editor. Poll /imports until its state is ready."""
Expand All @@ -290,17 +292,21 @@ def import_media(self, path, *, ref=None):
schema.validate(schema.obj({'ref': schema.REF}), dict(ref=ref))
return self._upload(path, '/imports', ref=ref)

def _upload(self, path, endpoint, *, ref=None):
def _upload(self, path, endpoint, *, ref=None, stage=None):
from .schema import MAX_UPLOAD_BYTES, MAX_JSON_UPLOAD_BYTES
source = local_file(path)
stage = stage or (lambda name: None)
try:
size = source.stat().st_size
if size > MAX_UPLOAD_BYTES:
raise ApiError(413, 'too_large', 'File exceeds upload limit',
dict(next='Reduce the file to at most %d bytes.' % MAX_UPLOAD_BYTES, details=[dict(limit_bytes=MAX_UPLOAD_BYTES)]))
if size * 4 // 3 + 4096 <= MAX_JSON_UPLOAD_BYTES:
if endpoint == '/media':
return self.upload_bytes(source.name, source.read_bytes())
data = source.read_bytes()
# A small body goes out at once; the wait is the server's check.
stage('checking')
return self.upload_bytes(source.name, data)
return self.post(endpoint, dict(name=source.name, data_base64=base64.b64encode(source.read_bytes()).decode(),
**({'ref': ref} if ref is not None else {})), timeout=600)
import secrets
Expand All @@ -315,10 +321,12 @@ def _upload(self, path, endpoint, *, ref=None):
if ref is not None else '') + '\r\n--%s--\r\n' % boundary).encode('ascii')
with source.open('rb') as stream:
def chunks():
stage('sending')
yield prefix
while data := stream.read(1024 * 1024):
yield data
yield ending
stage('checking')
return self.request('POST', '/freevideo/agent/v1' + endpoint, encoded=chunks(),
content_type='multipart/form-data; boundary=' + boundary,
content_length=len(prefix) + size + len(ending), timeout=600)
Expand Down
39 changes: 36 additions & 3 deletions freevideo_engine/agent/drama.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def __init__(self, problems):
FACE_ORDER = ('face', 'front', 'three_quarter') # the one picture standing for a person, then any other label, in card order
MAX_VOICES = 3 # voice clips per shot, the model limit
MAX_REFERENCES = 32
MAX_BEATS = 24 # beats of a screenplay outline


def check(play, single_seconds=BLOCK_SECONDS):
Expand Down Expand Up @@ -117,6 +118,30 @@ def validate(function, *args, **kwargs):
else:
for key, text in play['continuity'].items():
validate(specs._text, text, '/continuity/' + str(key).replace('~', '~0').replace('/', '~1'), 20000, empty=True)
if 'outline' in play:
# The story the user confirms before shooting: beats in order, each what happens and why.
outline = play['outline']
beats = outline.get('beats') if isinstance(outline, dict) else None
if not isinstance(beats, list) or not 1 <= len(beats) <= MAX_BEATS or set(outline) - {'beats'}:
problem('/outline', 'must be {"beats": [{"what", "why"}]} with 1 to %d beats' % MAX_BEATS)
else:
for j, beat in enumerate(beats):
path = '/outline/beats/%d' % j
if not isinstance(beat, dict) or set(beat) - {'what', 'why'} or not isinstance(beat.get('what'), str):
problem(path, 'must be {"what": what happens, "why": what causes it}')
continue
validate(specs._text, beat['what'], path + '/what', 500)
if 'why' in beat:
validate(specs._text, beat['why'], path + '/why', 500, empty=True)
scenes = play.get('blocking', {})
if not isinstance(scenes, dict):
problem('/blocking', 'must be an object of text by scene name')
scenes = {}
for key, text in scenes.items():
path = '/blocking/' + str(key).replace('~', '~0').replace('/', '~1')
if not isinstance(key, str) or not specs.SLUG.fullmatch(key):
problem(path, 'scene names must be short names')
validate(specs._text, text, path, 20000, empty=True)
if 'captions' in play:
validate(specs._bool, play['captions'], '/captions')
if 'quality' in play and (not isinstance(play['quality'], str) or play['quality'] not in specs.QUALITY):
Expand Down Expand Up @@ -150,7 +175,9 @@ def validate(function, *args, **kwargs):
problem(path + '/continue', '/shots/0 cannot continue a previous shot')
if 'continue' in shot:
validate(specs._bool, shot['continue'], path + '/continue')
for field in ('action', 'idea', 'prompt', 'camera', 'framing', 'sound', 'music'):
if 'scene' in shot and (not isinstance(shot['scene'], str) or shot['scene'] not in scenes):
problem(path + '/scene', 'must name a scene of blocking')
for field in ('action', 'idea', 'prompt', 'camera', 'framing', 'sound', 'music', 'blocking'):
if field in shot:
validate(specs._text, shot[field], path + '/' + field, 20000, empty=True)
if 'continuity' in shot:
Expand Down Expand Up @@ -351,6 +378,12 @@ def continuity():
rows.append(table['set'].strip().rstrip('.') + '.')
return ['Continuity - ' + ' '.join(rows)] if rows else []

def blocking():
"""Who stands where in this shot's scene; a shot's own blocking replaces its scene's for this shot only."""
scenes = play.get('blocking') if isinstance(play.get('blocking'), dict) else {}
text = shot.get('blocking') if isinstance(shot.get('blocking'), str) and shot['blocking'].strip() else scenes.get(shot.get('scene'))
return ['Blocking - ' + text.strip().rstrip('.') + '.'] if isinstance(text, str) and text.strip() else []

cuts = shot.get('cuts')
if cuts:
# Numbered cuts inside one generation: [Shot 1] has no time, the others say when they start.
Expand All @@ -362,7 +395,7 @@ def continuity():
row += [lead + '.' if lead else '']
if shot.get('continue'):
row.append('Continuing from the opening frame, the scene keeps its exact layout, lighting and characters.')
row += looks() + continuity()
row += looks() + continuity() + blocking()
elif cut.get('framing'):
row.append(cut['framing'].strip().rstrip('.') + '.')
if number == 1 and cut.get('framing'):
Expand All @@ -380,7 +413,7 @@ def continuity():
parts = ['[Shot 1] ' + (lead + '.' if lead else '')]
if shot.get('continue'):
parts.append('Continuing from the opening frame, the scene keeps its exact layout, lighting and characters.')
parts += looks() + continuity()
parts += looks() + continuity() + blocking()
parts.append(named(play, people, shot.get('action', '')))
if shot.get('camera'):
parts.append(shot['camera'])
Expand Down
6 changes: 6 additions & 0 deletions freevideo_engine/agent/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@
'job (30 s or more, or beyond the device limit, continues and joins automatically); to polish an idea first, run enhance, then generate with "prompt": {"from_job": "<enhance id>"} rather than retyping the text. '
'A film is cut into shots: see the rule "Cut by default" before making one long continued video.',
'Use drama plan → start → get → sheet → choose/reshoot → finish; inspect todo after every change.',
'Write the story the user agreed on into the screenplay as outline {beats: [{what, why}]}; the start confirmation shows the beats to the user. '
'Write where people stand once a scene in blocking {scene: who is left, right or centre, sitting or standing, facing whom, where the camera is} '
'and give each shot its scene; a shot that changes it writes its own blocking. Blocking and continuity go into every shot prompt.',
'Each finished output also gives project_id: the film opened as an editing project, one part per shot with its action as the note. '
'Finish the sound or change the cut on that project (projects get, then finish_sound); the MP4 in library_id stays as it was. '
'project_id is null where editing is not available.',
'A shot whose prompt has numbered cuts and several takes: do not stop at the first take unless every cut of it is good '
'(scores 7 or more of 10); wait for the other takes, read segments, and choose with pieces '
'[{take, start, end}] to take the best part of every cut from different takes. Cut only where the takes cut, '
Expand Down
1 change: 1 addition & 0 deletions freevideo_engine/agent/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ def retry_pending(self):
self._receipts.retry()

def close(self):
self.productions.close()
if self._receipts is not None:
self._receipts.close()

Expand Down
2 changes: 1 addition & 1 deletion freevideo_engine/agent/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def _compiled(play, shot):

# What a screenplay shot and a line may say; anything else would be ignored without a word.
SHOT_FIELDS = ('id', 'seconds', 'action', 'idea', 'prompt', 'camera', 'framing', 'sound', 'music', 'takes', 'seed',
'continue', 'first', 'references', 'cast', 'lines', 'cuts', 'continuity')
'continue', 'first', 'references', 'cast', 'lines', 'cuts', 'continuity', 'scene', 'blocking')
LINE_FIELDS = ('who', 'line', 'how', 'language')


Expand Down
Loading