Skip to content
5 changes: 5 additions & 0 deletions freevideo_engine/agent/editor_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,11 @@ def _fresh(self):
return context

def _text(self, name, request):
try:
request.encode('utf-8')
except UnicodeEncodeError:
# QuickJS ends the whole program on half of a character pair, wherever the text came from.
raise ValueError('The text holds half of a character pair') from None
with self._lock:
if self._context is None:
self._context = self._fresh()
Expand Down
11 changes: 7 additions & 4 deletions freevideo_engine/agent/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,16 @@
'job, never in a tight loop. The connection dropped or FreeVideo restarted: connect again, read the job list, '
'and continue; resend an unanswered request with the same ref, and never generate again what is already done.',
'Do not stop to report progress. A long task is not a reason to stop or to ask whether to go on.',
'When a video is done, tell the user it is in "Your creations" in FreeVideo, in plain words. Do not read out job, library '
'When a video is done, tell the user it is in "Creations" in FreeVideo, in plain words. Do not read out job, library '
'or project ids; give a file path only when the user asks for one. If the length differs from what was asked, '
'say the real length in seconds and do not explain frame counts.',
'Ask the user only when you would have to change the content or style they asked for, when the work will '
'take clearly longer than you told them at the start, or when you would have to delete or overwrite their '
'own works or edits.',
'Stay inside FreeVideo. Do not touch other files on this computer. Never change back something the user '
'edited. When the user tells you to stop, stop: cancel what you started and start nothing new.',
'edited. When the user tells you to stop, stop: cancel what you started and start nothing new. '
'When cancelled_by is "user", the user stopped this job on the page and the page already shows it as stopped: '
'send no message about it, and do not retry or resume it unless they ask.',
'Cast before you shoot. Search the cast library first. Use the card the user named, or one that clearly '
'matches; ask if you are unsure. Create a card only when none fits: from the user\'s photos if they gave any, '
'otherwise make its reference pictures with POST /cast/{id}/generate. '
Expand Down Expand Up @@ -213,12 +215,13 @@
'连接中断或 FreeVideo 重启后,重新连接,读取任务列表并继续;未收到响应的请求用相同的 ref 重发,'
'已经完成的内容不要重新生成。',
'不要为了汇报进度而停下。任务耗时较长,不是停止工作或询问是否继续的理由。',
'视频做好后,用通俗的话告诉用户它已经放进 FreeVideo 的“我的作品”里。不要念任务、作品或剪辑的编号;'
'视频做好后,用通俗的话告诉用户它已经放进 FreeVideo 的“作品”里。不要念任务、作品或剪辑的编号;'
'文件路径在用户问起时再给。实际长度和要求的不一样时,只说实际约几秒,不解释帧数。',
'只有需要改变用户要求的内容或风格、预计耗时明显超过开始时告诉用户的时间,'
'或者需要删除或覆盖用户自己的作品或修改时,才询问用户。',
'只在 FreeVideo 内工作,不要改动本机的其他文件,不要撤销用户做的修改。'
'用户要求停止时,取消你启动的任务,不再启动新任务。',
'用户要求停止时,取消你启动的任务,不再启动新任务。'
'cancelled_by 为 "user" 时,表示用户在页面上停止了该任务,页面已显示已停止:不要为此发消息;除非用户要求,否则不要重试或恢复。',
'先选角,再拍摄。先搜索角色库,使用用户指定的卡片或明确符合要求的卡片;拿不准时询问用户。'
'只有找不到合适卡片时才新建:用户提供了照片就用照片,否则通过 POST /cast/{id}/generate 生成参考图。'
'开拍前用一句话告诉用户选角结果。后续镜头使用卡片当前的图片。保留你创建的卡片,方便以后拍片复用。',
Expand Down
86 changes: 72 additions & 14 deletions freevideo_engine/agent/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ def image_reference(port, relative):
raise JobError('invalid_media', IMAGE_ERRORS['invalid_media']) from None


USER_STOP_NEXT = ('The user stopped this job on the page, and the page already shows it as stopped. Send no message '
'about it, and do not retry or resume it unless they ask. Wait for what they say next.')


def user_stop_notice(row):
"""What a job the user stopped on the page tells the agent. A job the agent cancelled, and one that finished
some other way before the stop took hold, read as before."""
stopped = row.get('cancelled_by') == 'user' and row.get('status') not in ('succeeded', 'failed', 'interrupted')
return dict(cancelled_by='user', next=USER_STOP_NEXT) if stopped else {}


class JobError(Exception):
def __init__(self, code, message, **details):
super().__init__(message)
Expand Down Expand Up @@ -96,9 +107,12 @@ def chain_error(code, message, next_action, *, details=None, retryable=False):


def single_block(spec, seconds=None):
from ..chain_plan import plan
return plan(spec['seconds'] if seconds is None else seconds, max_frames=single_frame_limit(spec),
continued=bool(spec.get('continue_from')))['single']
from ..chain_plan import PlanError, plan
try:
return plan(spec['seconds'] if seconds is None else seconds, max_frames=single_frame_limit(spec),
continued=bool(spec.get('continue_from')))['single']
except PlanError:
return False # Too long for one generation and it cannot be cut into parts: parts_problems says so.


def continuation_problems(spec, prefix='', *, editor=None):
Expand Down Expand Up @@ -151,13 +165,13 @@ def _continued_part_problem(seconds, chain):
return seconds < MIN_PART_SECONDS or any(block['frames'] < MIN_PART_FRAMES for block in chain['blocks'])


def length_problem(seconds, continued, prefix='', *, minimum=None):
"""Suggest nearby whole-second lengths the same planner and minimum accept."""
def length_problem(seconds, continued, prefix='', *, minimum=None, frames=None):
"""Suggest nearby whole-second lengths the same planner, device limit (`frames`) and minimum accept."""
from ..chain_plan import PlanError, plan
available = []
for candidate in range(max(1, int(seconds) - 30), int(seconds) + 31):
try:
chain = plan(candidate, continued=continued)
chain = plan(candidate, max_frames=frames, continued=continued)
except PlanError:
continue
if not continued or not _continued_part_problem(candidate, chain):
Expand All @@ -167,8 +181,11 @@ def length_problem(seconds, continued, prefix='', *, minimum=None):
nearby = [s for s in available if s < seconds][-2:] + [s for s in available if s > seconds][:2]
rule = ('at least %d s per continued part' % minimum if minimum is not None
else 'a length that can be split into supported parts')
allowed = rule + '; available lengths: ' + ', '.join('%d s' % s for s in nearby)
allowed = rule + ('; available lengths: ' + ', '.join('%d s' % s for s in nearby) if nearby
else '; this device makes no length near this one at this size')
problem = schema.diagnostic(prefix + '/seconds', allowed, seconds, nearest=nearby)
if not nearby:
problem['next'] = 'Try a smaller width and height.'
if minimum is not None:
problem['message'] = ('Each continued part must be at least %d seconds. Make it longer. ' % minimum
+ problem['message'])
Expand All @@ -193,9 +210,9 @@ def parts_problems(spec, runner, prefix='', *, editor=None, work=None, planned=N
try:
chain = plan(spec['seconds'], max_frames=frames, continued=continued)
except PlanError:
return [length_problem(spec['seconds'], continued, prefix)]
return [length_problem(spec['seconds'], continued, prefix, frames=frames)]
if continued and _continued_part_problem(spec['seconds'], chain):
return [length_problem(spec['seconds'], continued, prefix, minimum=MIN_PART_SECONDS)]
return [length_problem(spec['seconds'], continued, prefix, minimum=MIN_PART_SECONDS, frames=frames)]
if runner is None:
return []
limit = runner.max_parts
Expand Down Expand Up @@ -306,6 +323,17 @@ def iso(stamp):
return None


class _Warmed:
"""A submit's capability source inside Jobs.lock: the studio's answer as it stands, never a wait."""
def __init__(self, port):
self.port = port

def capability_snapshot(self, query):
table = self.port.capability_snapshot(query)
# Still being worked out after the wait before the lock: the unknown limit, as a timeout gives.
return dict(status='unavailable') if table.get('status') == 'computing' else table


class _ReceiptWrites:
"""Windows receipt outbox. Backoff never holds this lock or Jobs.lock.

Expand Down Expand Up @@ -578,6 +606,8 @@ def finish(self, row, status, *, result=None, error=None, save=True):
card = (row.get('parent') or {}).get('cast')
if card and '/resume' in error['next']:
error['next'] = CAST_AGAIN % card
if row.get('cancelled_by') == 'user' and status == 'cancelled':
error['next'] = USER_STOP_NEXT
if row['status'] == status and row.get('error') == error and row.get('finished_at') is not None:
if result is not None and row.get('result') != result:
row['result'] = result
Expand Down Expand Up @@ -638,16 +668,35 @@ def submit(self, value, *, client=None, takes=1, parent=None, editor=None):
payload = dict(value, **({'takes': takes} if takes != 1 else {}))
return self.submit_request(payload, client=client, parent=parent, editor=editor)[0]

def warm_capabilities(self, inputs):
"""Before Jobs.lock: each row waits here (length.WAIT_SECONDS at most) for a size asked for the first time."""
from .routes import substitute
names = {}
for raw in inputs:
item = substitute({k: v for k, v in raw.items() if k not in ('ref', 'takes')}, names)
if isinstance(raw.get('ref'), str):
names[raw['ref']] = specs.new_id() # Stands in for the id this row gets under the lock.
try:
specs.normalize(item, port=self.port)
except Exception:
pass # The locked pass reports it, in batch order.

def submit_request(self, value, *, client=None, key=None, parent=None, editor=None):
batch = isinstance(value, dict) and 'jobs' in value
schema.validate(schema.batch_schema(local=True) if batch else schema.job_schema(local=True), value)
schema.preflight_submission(value)
if key is not None or value.get('ref') is not None:
with self.lock:
_, replay = self.idempotency('submit', value, key, client)
if replay is not None:
return replay, True
inputs = value['jobs'] if batch else [value]
self.warm_capabilities(inputs)
with self.lock:
# Another submit may have committed the same key while its tables warmed.
receipt, replay = self.idempotency('submit', value, key, client)
if replay is not None:
return replay, True
batch = 'jobs' in value
inputs = value['jobs'] if batch else [value]
names, rows, problems = {}, [], []
for i, raw in enumerate(inputs):
prefix = '/jobs/%d' % i if batch else ''
Expand All @@ -660,7 +709,7 @@ def submit_request(self, value, *, client=None, key=None, parent=None, editor=No
from .routes import substitute
item = substitute(item, names)
try:
normalized = specs.normalize(item, port=self.port)
normalized = specs.normalize(item, port=_Warmed(self.port))
except specs.SpecError as error:
problems.extend(schema.prefix_diagnostic(e, prefix) for e in error.errors)
continue
Expand Down Expand Up @@ -739,11 +788,14 @@ def list(self, *, status=None, project=None, kind=None, limit=50):
and (not project or r['spec'].get('project') == project)]
return rows[:limit]

def cancel(self, identity):
def cancel(self, identity, *, by=None):
row = self.get(identity)
with self.lock:
if row['status'] in DONE:
return row
if by == 'user':
# Save before contacting an executor, including jobs still being dispatched.
self.touch(row, cancelled_by='user')
if row['kind'] == 'generate' and not row['spec'].get('refine_from'):
row['_cancel'] = True
row['cancel_requested'] = True
Expand Down Expand Up @@ -921,6 +973,7 @@ def resume(self, identity, *, key=None, value=None, replay_result=False, client=
row.setdefault('receipts', []).append(dict(receipt, jobs=[identity]))
row.pop('error', None)
row.pop('_cancel', None)
row.pop('cancelled_by', None)
self.touch(row, status='waiting', resume_pending=bool(row.get('chain_id')), cancel_requested=False,
cancelled_in_jobs=False,
chain_submission='linked' if row.get('chain_id') else None,
Expand Down Expand Up @@ -1008,6 +1061,10 @@ def view(self, row, *, detail=True):
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']))
notice = user_stop_notice(row)
result.update(notice)
if notice and result.get('error'):
result['error'] = dict(result['error'], next=USER_STOP_NEXT)
if detail:
result['spec'] = {k: v for k, v in spec.items() if k not in ('depends_on',)}
if row.get('comfy'):
Expand Down Expand Up @@ -1448,7 +1505,8 @@ def start_chain(self, row, *, payload=None, resume=False):
except (PlanError, ChainPartTooShort) as error:
row['resume_pending'] = False
problem = length_problem(row['spec']['seconds'], bool(row['spec'].get('continue_from')),
minimum=error.limit if isinstance(error, ChainPartTooShort) else None)
minimum=error.limit if isinstance(error, ChainPartTooShort) else None,
frames=single_frame_limit(row['spec']))
self.finish(row, 'failed', error=errors.validation([problem]))
return
except Exception:
Expand Down
17 changes: 15 additions & 2 deletions freevideo_engine/agent/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,20 @@ async def read_body(request, limit, optional=False):
data.extend(chunk)
if len(data) > limit:
raise JobError('too_large', 'Body exceeds limit', limit_bytes=limit)
return json.loads(data.decode('utf-8'), parse_constant=lambda _: (_ for _ in ()).throw(json.JSONDecodeError('Nonfinite JSON', '', 0)))
text = data.decode('utf-8')
value = json.loads(text, parse_constant=lambda _: (_ for _ in ()).throw(json.JSONDecodeError('Nonfinite JSON', '', 0)))
if '\\u' in text:
# An escape may spell half of a character pair. No file or page can hold that text, and the
# script runtime behind the editing rules ends the whole program on it.
try:
json.dumps(value, ensure_ascii=False).encode('utf-8')
except UnicodeEncodeError:
row = contract.diagnostic('/', 'text made of whole characters', None)
row.update(got='half of a character pair', next='Send whole characters and retry.',
message='The body holds half of a character pair (a lone \\ud800 to \\udfff escape); '
'no text can be stored that way.')
raise specs.SpecError(row) from None
return value

async def body(request, **ignored):
return request[body_key]
Expand Down Expand Up @@ -990,7 +1003,7 @@ async def activity_cancel(request):
return problem('unsupported_media_type', 'Send JSON', 415)
value = await body(request)
try:
row = await asyncio.to_thread(jobs.cancel, value.get('id') if isinstance(value, dict) else None)
row = await asyncio.to_thread(jobs.cancel, value.get('id') if isinstance(value, dict) else None, by='user')
except KeyError:
return problem('not_found', 'No such job', 404)
return reply(dict(id=row['id'], status=row['status']))
Expand Down
4 changes: 3 additions & 1 deletion freevideo_engine/agent/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@ def project_schema(name):
'move': (dict(clip_ids=ids, frames=number(-10**7, 10**7, integer=True)), ('clip_ids', 'frames'), 'Move selected clips by a signed relative frame offset. Use clip IDs from this revision; linked partners move together.'),
'describe': (dict(clip_ids=ids, text=dict(type='string', maxLength=8000)), ('clip_ids', 'text'), 'Set the description of selected clips. Use clip IDs from this revision and one paragraph of text.'),
'set_ref': (dict(clip_ids=ids, ref=dict(type='string', maxLength=128, pattern=r'^[A-Za-z0-9_-]{0,128}$')), ('clip_ids', 'ref'), 'Set an outside shot reference on selected clips. Use clip IDs from this revision; an empty ref removes it.'),
'note': (dict(clip_ids=ids, text=dict(type='string', maxLength=120)), ('clip_ids', 'text'), 'Write the one line the user sees under selected picture clips; without it they see the start of the generation text. Do it after you add, generate or change a clip: in the user\'s language, what is in the picture, subject first ("Train through morning mist"); a short clip shows only the first few characters. Not the clip\'s position (opening, part 2) and not its progress. Up to 120 characters on one line; an empty text removes it. It never changes the description used for generation.'),
'note': (dict(clip_ids=ids, text=dict(type='string', maxLength=400)), ('clip_ids', 'text'), 'Write the one line the user sees under selected picture clips; without it they see the start of the generation text. Do it after you add, generate or change a clip: in the user\'s language, what is in the picture, subject first ("Train through morning mist"); a short clip shows only the first few characters. Not the clip\'s position (opening, part 2) and not its progress. Up to 120 characters on one line; an empty text removes it. It never changes the description used for generation.'),
'use_version': (dict(clip_id=CLIP, index=number(0, 49, integer=True)), ('clip_id', 'index'), 'Restore a stored clip version. First get the clip ID and its versions array; index is zero based.'),
'subtitles': (dict(cues=array(obj(dict(start=EDITOR_FRAME, frames=number(1, 10**7, integer=True), text=dict(type='string', minLength=1, maxLength=400)),
('start', 'frames', 'text')), 1, 500), replace=dict(type='boolean')), ('cues',),
Expand Down Expand Up @@ -1297,6 +1297,8 @@ def openapi():
job_view = {'type': 'object', 'properties': dict(id=JOB, kind=enum(KINDS),
status=enum(('waiting', 'queued', 'running', 'cancelling', 'succeeded', 'failed', 'cancelled', 'interrupted')),
chain_id=string(), last_heartbeat_at={'type': ['string', 'null']}, poll_after_ms={'type': ['integer', 'null']},
cancelled_by=enum(('user',), description='Present when the user stopped this job on the page.'),
next=string(description='Instructions for the agent after a user stop.'),
spec={'type': 'object'}, progress={'type': 'object'}, result={'type': 'object'},
waiting_for=array(string()), notice=string(),
error={'$ref': '#/components/schemas/Error'})}
Expand Down
1 change: 1 addition & 0 deletions freevideo_engine/image_gen/licenses/NOTICE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Qwen is licensed under the Qwen RESEARCH LICENSE AGREEMENT, Copyright (c) 2026 Hangzhou Tongyi Laboratory Technology Co., Ltd. All Rights Reserved.
Loading