Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
2fdf5d4
Image model loading shows its progress on the bar
KBRASK Oct 14, 2026
22f7bd6
Editor shows the image model download state
KBRASK Oct 14, 2026
586e063
Install, offline package and portable launcher fixes from the bug hunt
KBRASK Oct 14, 2026
82761e6
Agent tools no longer stall on the first call on Windows
KBRASK Oct 14, 2026
7dca263
Open or create editing projects from the view switch
KBRASK Oct 14, 2026
a62d123
Agent listening job: a forgotten subtitle job fails as listen_failed …
KBRASK Oct 14, 2026
a5c69dc
Smooth timeline playback with media clocks and ready handoffs
KBRASK Oct 14, 2026
1002fb8
Add first-open editor guide with grouped replay
KBRASK Oct 14, 2026
e5fc6d6
LoRA trigger words in creation, Agent and chain prompts
KBRASK Oct 14, 2026
1e43b6a
Agent conversations: delete from the row menu; a deleting one takes n…
KBRASK Oct 14, 2026
a2f2eed
Editor: a failed step says what went wrong and offers the diagnostic …
KBRASK Oct 14, 2026
d74eea7
Image generation explains why an empty description does not start a p…
KBRASK Oct 14, 2026
95b91d1
Image worker exits after 45 s idle so its memory returns sooner
KBRASK Oct 14, 2026
d0831d2
Image generation failures say why on the card and offer the diagnosti…
KBRASK Oct 14, 2026
a5c1a4c
Settings show validation, avoid overlapping saves, and show measureme…
KBRASK Oct 14, 2026
a44140c
Release page links the ModelScope offline folder next to Quark
KBRASK Oct 14, 2026
58d8cbe
Chat keeps drafts, recovers actions without duplicate sends, and says…
KBRASK Oct 14, 2026
947afc6
Product terms stay untranslated when the browser translates the page
KBRASK Oct 14, 2026
499c327
Creation panel keeps settings, prompt and media after refresh, reopen…
KBRASK Oct 14, 2026
18e1052
Duration controls explain lengths beyond one run and keep the forced …
KBRASK Oct 14, 2026
888e54e
Part-by-part videos report cancel, resume and finished states after r…
KBRASK Oct 14, 2026
65845ce
Result actions, sharing and second pass report failures and progress;…
KBRASK Oct 14, 2026
4865efb
Creation panel asks for empty duration, size, seed or steps instead o…
KBRASK Oct 14, 2026
75fd979
LoRA list keeps the three most recent at the top and adds Clear
KBRASK Oct 14, 2026
211308d
Reminder when a long run finishes in a background tab
KBRASK Oct 14, 2026
2fb0055
Windows int8 second pass sizes refinement heads from live allocator l…
KBRASK Oct 14, 2026
c40cbde
Text encoder reads ahead in a bounded window when full prefetch is sk…
KBRASK Oct 14, 2026
07cb117
Setup page copy trimmed, hints left-aligned
KBRASK Oct 14, 2026
0953c5a
Shorter generation failure card and diagnostics copy
KBRASK Oct 14, 2026
ac1f853
Editor picture properties: aligned number boxes with units inside
KBRASK Oct 14, 2026
611c581
Floating view navigation fits narrow windows
KBRASK Oct 14, 2026
fef837a
Reference strip fades at the scrolled-out edge
KBRASK Oct 14, 2026
3f26702
Launcher settings: per-platform Python path placeholder, no repair no…
KBRASK Oct 14, 2026
19bff43
Setup Stop button shown only while a check or installation runs
KBRASK Oct 14, 2026
aab8820
Past chats show a small cover from their first finished video
KBRASK Oct 14, 2026
8e4554b
Shorter interface copy; first view-switch tip removed
KBRASK Oct 14, 2026
5d68feb
Progress keeps reporting during the upscale before the second pass
KBRASK Oct 14, 2026
d446ea9
Agent chat picture cards open a zoomable viewer
KBRASK Oct 14, 2026
db8840e
Timeline links show their kind
KBRASK Oct 14, 2026
4ae8cfa
Prompt suggestions for camera and motion words
KBRASK Oct 14, 2026
b4c46ed
Detailed generation progress with stage words and real percentages
KBRASK Oct 14, 2026
1265ff1
Editor toolbar stays on one row at phone widths
KBRASK Oct 14, 2026
edd14a5
Prompt pop-ups (media mentions and camera words) open under the line …
KBRASK Oct 14, 2026
47715e6
Continued parts keep the ten-second minimum while editing
KBRASK Oct 14, 2026
b35c7d0
Agent films: screenplay errors name the field; unknown shot and line …
KBRASK Oct 14, 2026
53e5b86
Agent films: take sheets show one frame per second by default
KBRASK Oct 14, 2026
67a02f1
Editor: queued parts keep their label after a reload; an edit opens f…
KBRASK Oct 14, 2026
45be456
Editor: the generation bar keeps its layout while a part is made; the…
KBRASK Oct 14, 2026
0bf2d8e
AI autopilot: Settings stays in the header on narrow windows; the con…
KBRASK Oct 14, 2026
b5565fa
Editor: a too-long or too-short extension shows its reason once, with…
KBRASK Oct 14, 2026
78c046f
Settings: the model-folder box is taller and its status sits beside t…
KBRASK Oct 14, 2026
3b9f52e
Editor: keep the project name readable in narrow windows
KBRASK Oct 14, 2026
6193afb
Release 0.4.5
KBRASK Oct 14, 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
2 changes: 1 addition & 1 deletion freevideo_engine/agent/builtin_director.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ def run(tools, production_id, *, ask, stop, wait=25):
fingerprint = _ref(production_id, 'review', shot)
if fingerprint in handed_off or shot['chosen'] is not None or shot['chosen_by'] == 'user':
continue
sheets = tools.sheet(production_id, shot['id'], frames=6)
sheets = tools.sheet(production_id, shot['id'], frames=None)
if stop():
return
decision = judge(shot, sheets, ask=ask)
Expand Down
12 changes: 8 additions & 4 deletions freevideo_engine/agent/cast.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ def __init__(self, code, message, next_action, *, card=None, job=None):
def view(self):
value = errors.build(self.code, validation=True, details=[], message=self.message,
next_action=self.next_action)
if self.code in errors.IMAGE_REASONS:
value.update(errors.image_failure(self.code))
if self.card is not None:
value['card'] = deepcopy(self.card)
if self.job is not None:
Expand Down Expand Up @@ -586,9 +588,9 @@ def generate(self, identity, value=None, *, by='builtin', ref=None, revision=Non
raise spec.SpecError([schema.diagnostic('/face', 'false for a setting card', True)])
ready = value.get('source') != 'video' and getattr(self.jobs.port, 'image_ready', lambda: False)()
source = value.get('source', 'image' if ready else 'video')
if source == 'image' and not ready:
raise CastError('pack_missing', errors.IMAGE_ERRORS['pack_missing'],
'omit source, or use source video')
# An explicit image request goes through the image service even when
# its pack is absent: that failure owns a diagnostic report and a
# durable job/card, just like a picture requested directly.
seed = secrets.randbelow(spec.MAX_SEED + 1)
token = uuid.uuid4().hex[:12]
if source == 'image':
Expand Down Expand Up @@ -725,9 +727,11 @@ def _land_image(self, identity, generating, hidden, rows):
row = rows[job]
if row is None or row['status'] != 'succeeded':
code = (row.get('error') or {}).get('code') if row else 'not_found'
code = code or (row['status'] if row else 'not_found')
detail = errors.image_failure(code, (row.get('error') or {}).get('report_id') if row else None)
failure = failure or dict(code=code or row['status'], job=job, message=(
'The generation was cancelled.' if row and row['status'] == 'cancelled'
else 'The reference picture did not finish.'))
else detail['reason']['en']), **detail)
continue
folder = 'freevideo/agent/%s/ref-%s' % (identity, job)
file = folder + '-0.png'
Expand Down
6 changes: 4 additions & 2 deletions freevideo_engine/agent/comfy.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,10 @@ def image_start(self, action, value):
return self.image.start(action, value)['job']
except ValueError as error:
from .jobs import IMAGE_ERRORS, IMAGE_WAIT_ERRORS
code = str(error) if str(error) in IMAGE_ERRORS else 'invalid_request'
raise JobError(code, IMAGE_ERRORS[code], retryable=code in IMAGE_WAIT_ERRORS) from None
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

def image_status(self, key):
return self.image.status(key)
Expand Down
60 changes: 57 additions & 3 deletions freevideo_engine/agent/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,39 @@
})


# Static bilingual image reasons; backend exception strings never enter the API.
IMAGE_REASONS = {
'no_memory': ('Not enough GPU memory. Make the picture smaller in "Image size…" and try again.',
'显存不足。在“图像大小…”里缩小后再试。'),
'busy_video': ('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。'),
'pack_missing': ('Download "Image generation models" in the launcher first.',
'请先在启动器里下载“图像生成模型”。'),
'model_unreadable': ('The image generation model files could not be read. Repair them in the launcher and try again.',
'图像生成模型的文件读不出来。在启动器里修复后再试。'),
'device_unavailable': ('The graphics card is not available right now. Try again later.',
'显卡暂时用不了,稍后再试。'),
'worker_exit': ('Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'),
'timeout': ('This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'),
'generation_failed': ('Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'),
'invalid_media': ('A reference picture is missing or cannot be read. Choose a readable picture.',
'参考图片不存在或读不出来,请选择可读取的图片。'),
'invalid_request': ('The picture settings are invalid. Check them and try again.', '图片参数不合法,请检查后重试。'),
'busy': ('Another picture is being generated. Try again when it finishes.', '已有一张图在生成,完成后再试。'),
'cancelled': ('The picture was cancelled.', '已取消这张图片。'),
}


def image_failure(code, report_id=None):
"""Only fixed sentences and a validated opaque report ID cross the image boundary."""
code = code if isinstance(code, str) else 'generation_failed'
en, zh = IMAGE_REASONS.get(code, IMAGE_REASONS['invalid_request'] if code.startswith('invalid_')
or code == 'too_many_images' else IMAGE_REASONS['generation_failed'])
valid = isinstance(report_id, str) and re.fullmatch(r'[0-9a-f]{32}', report_id)
return dict(reason=dict(en=en, zh=zh), report_id=report_id if valid and code not in (
'busy', 'cancelled', 'invalid_request', 'invalid_media', 'invalid_prompt', 'invalid_dimensions',
'invalid_seed', 'too_many_images') else None)


# Recovery is a function of the committed task and phase, not just an error code.
IMAGE_ERRORS = {
'invalid_request': 'The picture request contains invalid fields.',
Expand All @@ -131,7 +164,10 @@
'closed': 'The image service has shut down.',
'image_unavailable': 'The image service is not loaded on this computer.',
'no_memory': 'There is not enough GPU or system memory for this picture.',
'generation_failed': 'The image model could not finish this picture.',
'model_unreadable': IMAGE_REASONS['model_unreadable'][0],
'device_unavailable': IMAGE_REASONS['device_unavailable'][0],
'timeout': IMAGE_REASONS['timeout'][0],
'generation_failed': IMAGE_REASONS['generation_failed'][0],
'worker_exit': 'The image worker stopped before finishing this picture.',
'cancelled': 'The picture job was cancelled.',
}
Expand Down Expand Up @@ -164,7 +200,16 @@
}


# A conversation that is not there: the user may delete one while the agent still works or waits in it.
SESSION_GONE = ('This conversation does not exist; the user may have deleted it.',
'Do not repeat elsewhere what you were saying or doing for it. Call the wait again without a session '
'(GET /freevideo/agent/v1/inbox?wait=50; MCP: freevideo_wait without id) and carry on in the session '
'it names.')


def recovery(code, *, kind=None, stage='request', resumable=True):
if kind == 'session' and code == 'not_found':
return SESSION_GONE[1]
if kind == 'image' and code == 'pack_missing':
return ERRORS[code][3]
if kind == 'image' and code == 'no_memory':
Expand Down Expand Up @@ -204,16 +249,21 @@ def safe_details(value):


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):
action_required=True, retry_after_seconds=None, owner=None, kind=None, stage='request', resumable=True,
report_id=None):
code = code if code in ERRORS else 'internal_error'
_, retryable, default_message, _ = ERRORS[code]
action = recovery(code, kind=kind, stage=stage, resumable=resumable)
if kind == 'image':
default_message = IMAGE_ERRORS.get(code, default_message)
if kind == 'session' and code == 'not_found':
default_message = SESSION_GONE[0]
result = dict(code=code, message=message if validation and message else default_message,
details=(details or []) if validation else safe_details(details or []),
retryable=retryable, request_id=request or request_id(),
docs='/freevideo/agent/v1/guide#errors')
if kind == 'image':
result.update(image_failure(code, report_id))
if action_required:
result['next'] = next_action if validation and next_action else action
if code == 'busy':
Expand All @@ -233,5 +283,9 @@ def validation(errors, code='invalid_request'):


def text(error):
return '%s: %s%s' % (error['code'], error['message'],
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,
' Diagnostic report: /freevideo/image-gen/report/' + diagnostic if diagnostic else '',
' Next: ' + error['next'] if error.get('next') else '')
21 changes: 16 additions & 5 deletions freevideo_engine/agent/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ def __init__(self, code, message, **details):
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'))
retry_after_seconds=self.details.get('retry_after_seconds'),
kind=self.details.get('kind'), report_id=self.details.get('report_id'))


class ManagedByProduction(JobError):
Expand Down Expand Up @@ -455,6 +456,8 @@ def __init__(self, port, directory, *, clock=now):
# Optional executor hook: called as parent_allowed(row['parent']) before a
# child with a parent may start. A False answer records the job as cancelled.
self.parent_allowed = None
# Optional: called as session_open(sid) while work for a conversation is committed; raises if it is gone.
self.session_open = None
from .projects import Projects
# The page keeps its projects under ComfyUI's output folder; loading may already poll project jobs.
self.projects = Projects(port.output_root)
Expand Down Expand Up @@ -591,10 +594,11 @@ def finish(self, row, status, *, result=None, error=None, save=True):
code = error.get('code', 'internal_error')
if code == 'invalid_json' and row['kind'] in ('vlm', 'enhance'):
code = 'vlm_invalid_json'
if code != error.get('code') or not error.get('request_id') or row['kind'] == 'image' and code in IMAGE_ERRORS:
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'))
kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from'),
report_id=error.get('report_id'))
if error is not None:
error = dict(error, request_id=row.get('request_id') or error['request_id'])
if error.get('next'):
Expand Down Expand Up @@ -765,6 +769,8 @@ def submit_request(self, value, *, client=None, key=None, parent=None, editor=No
if receipt:
receipt['jobs'] = [r['id'] for r in rows]
rows[0]['receipts'] = [receipt]
if self.session_open is not None and rows and rows[0].get('session'):
self.session_open(rows[0]['session']) # Deleting a conversation checks for work under this same lock.
self.save_batch(rows)
self.jobs.update({r['id']: r for r in rows})
return rows, False
Expand Down Expand Up @@ -1058,6 +1064,10 @@ def view(self, row, *, detail=True):
result['error'] = row['error'] if row['error'].get('request_id') else errors.build(
row['error'].get('code'), details=row['error'].get('details'), request=row.get('request_id'),
kind=row['kind'], stage='terminal', resumable=not row['spec'].get('refine_from'))
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'))
if result['error'].get('next'):
result['error'] = dict(result['error'], next=errors.recovery(result['error']['code'], kind=row['kind'],
stage='terminal', resumable=not row['spec'].get('refine_from')).replace('{id}', row['id']))
Expand Down Expand Up @@ -1434,6 +1444,7 @@ def resolve():
return assets
assets = resolve()
graph = specs.comfy_prompt(spec, node, prompt=prompt, assets=assets)
prompt = graph[node]['inputs']['text']
with self.lock:
row['inputs'] = dict(media=assets, prompt_chars=len(prompt))
if row.get('_cancel'):
Expand Down Expand Up @@ -1754,7 +1765,7 @@ def poll_image(self, row):
self.finish(row, 'failed', error=dict(code='unknown_job'))
return
phase = state.get('phase')
if phase in ('starting', 'progress', 'cancelling'):
if phase in ('queued', 'starting', 'progress', 'cancelling'):
row['_phase'] = state.get('stage') if phase == 'progress' else phase
elif phase == 'complete':
from PIL import Image
Expand All @@ -1772,7 +1783,7 @@ def poll_image(self, row):
else:
code = state.get('error') or 'generation_failed'
if not self.wait_image(row, code):
self.finish(row, 'failed', error=dict(code=code))
self.finish(row, 'failed', error=dict(code=code, report_id=state.get('report_id')))

# Local VLM -----------------------------------------------------------------------------------------
def start_vlm(self, row):
Expand Down
14 changes: 14 additions & 0 deletions freevideo_engine/agent/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ def short_drama(arguments):
Use ref to replay writes safely. Ordinary job batches remain available through freevideo_submit_batch."""


def preload():
"""Load what tool calls import before stdin is read.

Tool calls run on threads while this thread waits on stdin. On Windows a DLL loaded for the first
time then (numpy's OpenBLAS, loaded through spec) can wait for the client's next line, so the first
tool call would stall."""
from . import errors, schema, spec # noqa: F401
try:
from PIL import Image # noqa: F401
except ImportError:
pass


class Server:
def __init__(self, client, out=None):
self.client = client
Expand All @@ -129,6 +142,7 @@ def serve(self, stream=None):
self.client.hello(via='mcp')
except (ApiError, AttributeError):
pass # Starting before FreeVideo is allowed; later authenticated requests mark it online.
preload()
for line in stream:
line = line.strip()
if not line:
Expand Down
43 changes: 39 additions & 4 deletions freevideo_engine/agent/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,40 @@ def _compiled(play, shot):
return drama.prompt(play, shot)


def plan(request):
"""Return {'request': normalized copy, 'preview': {'shots': [...], 'total': N}}."""
# 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')
LINE_FIELDS = ('who', 'line', 'how', 'language')


def _rule(row):
"""A screenplay problem as a rule about its field; the pointer already travels in path.

A leading pointer in the text would read as a local path once paths are removed."""
if isinstance(row.get('allowed'), str):
rule = row['allowed']
return rule if rule.startswith('must') else 'must be ' + rule
return re.sub(r'^/\S*\s+', '', row['message']) if isinstance(row.get('message'), str) else 'is not valid'


def _fields(play, problems):
for i, shot in enumerate(play.get('shots') or [] if isinstance(play, dict) else []):
if not isinstance(shot, dict):
continue
rows = [('/screenplay/shots/%d/' % i, key, SHOT_FIELDS) for key in shot if key not in SHOT_FIELDS]
for j, line in enumerate(shot.get('lines') if isinstance(shot.get('lines'), list) else []):
if isinstance(line, dict):
rows += [('/screenplay/shots/%d/lines/%d/' % (i, j), key, LINE_FIELDS)
for key in line if key not in LINE_FIELDS]
for prefix, key, known in rows:
name = str(key).replace('~', '~0').replace('/', '~1')
problems.append(dict(path=prefix + name, message='is not a field here; use ' + ', '.join(known)))


def plan(request, *, fields=True):
"""Return {'request': normalized copy, 'preview': {'shots': [...], 'total': N}}.

fields=False rechecks a saved screenplay, which may predate the unknown-field rule."""
problems = []
if not isinstance(request, dict):
_invalid([dict(path='', message='A production request must be an object')])
Expand All @@ -83,7 +115,9 @@ def plan(request):
drama.check(play)
except drama.Screenplay as error:
play_ok = False
problems.extend(dict(path='/screenplay' + p['path'], message=p['message']) for p in error.problems)
problems.extend(dict(path='/screenplay' + p['path'], message=_rule(p)) for p in error.problems)
if fields:
_fields(play, problems)
count = request.get('takes_per_shot', 1)
count_ok = _count(count, '/takes_per_shot', problems)
director = request.get('director', 'agent')
Expand Down Expand Up @@ -548,7 +582,8 @@ def reshoot(state, shot, *, by, note=None, changes=None, takes=1, reason=None, n
source = draft['screenplay']['shots'][row['index'] - 1]
source.update(deepcopy(changes or {}))
# Recheck the complete effective screenplay, transactionally.
plan(dict(screenplay=draft['screenplay'], takes_per_shot=draft['takes_per_shot'], director=draft['director']))
plan(dict(screenplay=draft['screenplay'], takes_per_shot=draft['takes_per_shot'], director=draft['director']),
fields=False)
_schedule(draft, row, takes, by, note)
draft['cancelled'], draft['current_join'] = False, None
_decision(draft, now, by, 'reshoot', shot, reason=reason)
Expand Down
Loading