diff --git a/__init__.py b/__init__.py
index b6214c5a..50ac057a 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,4 +1,8 @@
"""Optional ComfyUI custom-node entry point; the engine also runs standalone."""
+from .freevideo_engine.comfy_logging import configure_managed_logging
+
+configure_managed_logging()
+
WEB_DIRECTORY = './web'
diff --git a/constraints/windows-portable.txt b/constraints/windows-portable.txt
index 5d576087..d6efcb15 100644
--- a/constraints/windows-portable.txt
+++ b/constraints/windows-portable.txt
@@ -22,14 +22,6 @@ comfy-angle==0.1.1
comfy-kitchen==0.2.33
comfyui-embedded-docs==0.5.11
comfyui-frontend-package==1.51.10
-comfyui-workflow-templates==0.11.55
-comfyui-workflow-templates-core==0.3.333
-comfyui-workflow-templates-json==0.1.68
-comfyui-workflow-templates-media-api==0.3.84
-comfyui-workflow-templates-media-assets-01==0.1.39
-comfyui-workflow-templates-media-image==0.3.160
-comfyui-workflow-templates-media-other==0.3.229
-comfyui-workflow-templates-media-video==0.3.101
cryptography==50.0.1
diffusers==0.40.0.dev0
einops==0.8.2
diff --git "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md"
index 76a1d8bc..9aefd83d 100644
--- "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md"
+++ "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md"
@@ -21,7 +21,7 @@
3. 需要源视频的声音时,勾选“保留源视频声音”。
4. 描述可以留空,也可以补充画面要求;然后点“生成视频”。
-替换人物不能与首帧、尾帧同时使用。首次使用时会自动下载替换人物模型(约 80 MB),面板中显示进度,下载完成后才能生成。源视频至少 1 秒;源视频没有声音时,不能保留声音。
+源视频里有多个人时,可以继续添加人物图(最多 2 张),人物图按画面中人物从左到右的顺序对应;只有一张人物图时,与原来相同。替换人物不能与首帧、尾帧同时使用。首次使用时会自动下载替换人物模型(约 80 MB),面板中显示进度,下载完成后才能生成。源视频至少 1 秒;源视频没有声音时,不能保留声音。
## 首帧、尾帧和参考的区别
diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py
index 491a374a..92ee884c 100644
--- a/freevideo_engine/agent/errors.py
+++ b/freevideo_engine/agent/errors.py
@@ -252,7 +252,7 @@ def recovery(code, *, kind=None, stage='request', resumable=True):
return 'Tell the user to free disk space, then submit the picture again with a new ref.'
if kind == 'music' and code == 'out_of_memory':
return 'Close other GPU applications, then submit the music job again with a new ref.'
- if kind in ('stems', 'sound', 'finish_sound', 'voice', 'voice_lines') and code == 'out_of_memory':
+ if kind in ('stems', 'sound', 'finish_sound', 'voice', 'voice_lines', 'voice_say') and code == 'out_of_memory':
return 'Close other GPU applications, then submit the job again with a new ref.'
if kind == 'image' and code == 'no_memory':
return 'Lower the picture dimensions or use fewer reference pictures, then submit with a new ref.'
diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py
index c5a435cd..2ffceea7 100644
--- a/freevideo_engine/agent/guide.py
+++ b/freevideo_engine/agent/guide.py
@@ -148,7 +148,7 @@
'Music: POST /projects/{id}/music with the revision you read and a caption; wait for the job, then read the project: result.clip_ids are the new clips on a2. Run it after the cut is joined; a second run needs the earlier score removed from a2.',
'Split sound: POST /projects/{id}/stems with the revision you read and the clip_id of a sound clip; wait for the job. The clip\'s sound is split into dialogue, music and ambience on tracks a4, a5 and a6 over the clip\'s range, and the clip itself goes silent. result.parts lists each track with its name, clip_id, start, end and frames. Offered only where features lists projects.stems; a clip is split once, and deleting the three parts gives its sound back. To save the three tracks and the original mix as four wav files, POST /projects/{id}/stems/export with the revision and the split clip\'s clip_id; the answer lists folder and files at once.',
'Finish the sound: after the cut is joined, POST /projects/{id}/finish_sound with the revision you read; wait for the job. In one job it splits each picture clip\'s sound where separation is offered and removes the separated music where it is a sustained score (shots, explosions and crowds filed there stay, at the ambience level), lays one score on a2 for the whole cut (music.caption composes it, music.source lays a file; music already on a2 stays), evens the dialogue, sets the music 16 LU under it, brings the cut to -18 LUFS with a -1 dBTP peak ceiling and fills digital silence with room tone. result gives loudness_lufs, true_peak_db, music_removed (per block: removed or kept, with the levels) and the steps in skipped; skipped.music is too_long for a cut over 600 s, and the rest still runs; then tell the user: The cut is over 10 minutes, so no music was composed. The other steps are done. To add music, run this again with a music file. Run again, only the clips split this time are judged and levelled.',
- 'Change a voice: POST /projects/{id}/voice/lines with the revision you read and a selection (clip_id, and start and end in seconds if only part) to write down the lines; read result.lines. Change the text of the lines to redo and POST /projects/{id}/voice/replace with the same selection and all lines (original_text unchanged); only the changed lines are spoken again, in the voice of the original, and the rest keep their sound. result.lines gives where each new line starts and ends; a line that cannot fit before the next one keeps its original voice with replaced false and warning too_long. POST /projects/{id}/voice/estimate with the lines answers at once with the seconds the changed lines take. Offered only where features lists projects.voice. Where features lists projects.voice.install instead, the dubbing model is not installed yet: the person downloads it (about 4.9 GB) from the voice change bar in the editor, and no agent route does it.',
+ 'Change a voice: POST /projects/{id}/voice/lines with the revision you read and a selection (clip_id, and start and end in seconds if only part) to write down the lines; read result.lines. Change the text of the lines to redo and POST /projects/{id}/voice/replace with the same selection and all lines (original_text unchanged); only the changed lines are spoken again, in the voice of the original, and the rest keep their sound. result.lines gives where each new line starts and ends; a line that cannot fit before the next one keeps its original voice with replaced false and warning too_long. POST /projects/{id}/voice/estimate with the lines answers at once with the seconds the changed lines take. POST /projects/{id}/voice/say with text, seconds and voice.reference (a sound clip of the project, and start and end in seconds if only part) says a new line in that voice; result.source is the new sound, which the job does not place. Offered only where features lists projects.voice. Where features lists projects.voice.install instead, the dubbing model is not installed yet: the person downloads it (about 4.9 GB) from the voice change bar in the editor, and no agent route does it.',
'Subtitles: POST /projects/{id}/listen with mode transcribe (writes down what is said) or align (times the lines you give) and the revision you read; wait for the job and read result.lines, each with start and end frames. Then add them with a subtitles operation of cues {start, frames, text}; replace true swaps the whole track. With no speech model the job fails with no_speech_model: write the lines yourself.',
'The user sees one line under each picture clip: its note, or else the first line of its description. A description is generation text, often long and in English, so after you add, generate or change a clip, write a note (operation note) in the user\'s language saying what is in the picture: the subject first, then the setting, for example "Train through morning mist" (in Chinese "小火车穿过晨雾"). A short clip shows only the first few characters, about four in Chinese, and those must already make sense on their own. Do not write where the clip sits (opening, middle, ending, part 2): the order of the clips shows that. Do not write how far along it is: the clip already shows its progress. Keep it true when you change the clip again.',
'Reuse ref or Idempotency-Key only for the identical request. Source video/audio/thumbnail URLs require the same Agent token.',
@@ -394,6 +394,7 @@
'统一人声响度,整片调至 -18 LUFS,并以环境底噪填补无声处。先读取 revision,等待返回的任务完成',
('POST', '/projects/{id}/voice/lines'): '把选中声音片段里说的话逐句写下来,每句带起止秒数;先读取 revision 与片段 ID,等待返回的任务完成,结果 lines 给出各句;仅在 features 含 projects.voice 时可用',
('POST', '/projects/{id}/voice/replace'): '把改过文字的句子用原说话人的声音重新说一遍,放回原句的位置,没改的句子保持原声;提交 voice/lines 给出的全部句子,只改 text;结果 lines 给出每句新声的起止,放不进下一句之前的句子保留原声',
+ ('POST', '/projects/{id}/voice/say'): '用项目里一个声音片段的音色说一句新的话,长度按给定秒数;参考文字缺时先识别。等待返回的任务完成,结果 source 是新声音,任务不放到时间线上',
('POST', '/projects/{id}/voice/shorten'): '按目标秒数改短一句的文字;只返回文字,不保存也不配音',
('POST', '/projects/{id}/voice/estimate'): '估算改过的句子配出来的秒数;立即返回',
('POST', '/projects/{id}/stems/export'): '把已拆开片段的人声、音乐、环境声和原混音保存为四个 wav 文件;先读取 revision 与被拆片段的 ID,返回立即给出文件夹和文件名',
diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py
index 2224c097..7b41ce1c 100644
--- a/freevideo_engine/agent/jobs.py
+++ b/freevideo_engine/agent/jobs.py
@@ -32,7 +32,7 @@
ACTIVE = ('waiting', 'queued', 'running', 'cancelling')
CAST_AGAIN = 'POST /freevideo/agent/v1/cast/%s/generate to make the reference pictures again.'
DONE = ('succeeded', 'failed', 'cancelled', 'interrupted')
-GPU_KINDS = ('vlm', 'enhance', 'image', 'music', 'stems', 'finish_sound', 'voice')
+GPU_KINDS = ('vlm', 'enhance', 'image', 'music', 'stems', 'finish_sound', 'voice', 'voice_say')
IMAGE_ERRORS = errors.IMAGE_ERRORS
IMAGE_WAIT_ERRORS = ('busy', 'busy_video')
IMAGE_WAIT_NOTICES = {
@@ -1230,6 +1230,9 @@ def view(self, row, *, detail=True):
result['progress'] = progress
if row.get('chain_id'):
result['last_heartbeat_at'] = result['progress'].pop('last_heartbeat_at', None)
+ elif row['status'] == 'succeeded' and row['kind'] in ('generate', 'image'):
+ # A finished job reads as whole, so a client following the fraction sees it reach the end.
+ result['progress'] = dict(phase='complete', fraction=1.)
if row.get('result') is not None:
result['result'] = result_view(row['result']) if row['kind'] == 'generate' else assets.public(row['result'])
if row.get('error') is not None:
@@ -1277,6 +1280,14 @@ def progress(self, row):
block=dict(index=current['index'] + 1 if current else min(chain['done'] + 1, chain['count']),
total=chain['count'], done=chain['done']),
last_heartbeat_at=iso(chain.get('last_heartbeat_at')))
+ # How far the part being made has come, decoding and saving included (the step count stops at the
+ # last step), and each part's frames: the overall fraction weighs the parts by them.
+ if chain['count'] > 1:
+ if current and isinstance(current.get('fraction'), (int, float)):
+ value['block']['fraction'] = min(1., max(0., float(current['fraction'])))
+ frames = [(b.get('inputs') or {}).get('frames') for b in chain['blocks']]
+ if len(frames) == chain['count'] and all(type(f) is int and f > 0 for f in frames):
+ value['block']['frames'] = frames
if value['phase'] == 'sampling' and message.get('done') is not None:
value['step'] = dict(done=message['done'], total=message.get('total'), **({'unit': message['unit']} if message.get('unit') else {}))
for key in ('label', 'detail', 'fraction', 'elapsed_seconds', 'remaining_seconds'):
@@ -1497,6 +1508,9 @@ def run():
elif row['kind'] == 'voice':
from .voice import run_voice_replace
result = run_voice_replace(self, row)
+ elif row['kind'] == 'voice_say':
+ from .voice import run_voice_say
+ result = run_voice_say(self, row)
else:
result = self.run_assemble(row)
return result
diff --git a/freevideo_engine/agent/mcp.py b/freevideo_engine/agent/mcp.py
index 80ad868d..41d51d51 100644
--- a/freevideo_engine/agent/mcp.py
+++ b/freevideo_engine/agent/mcp.py
@@ -24,7 +24,7 @@
JOB_WAIT = 45. # freevideo_wait only; the other waits keep 25 s. Fewer empty turns, each of which carries the whole context again.
PROTOCOLS = ('2025-06-18', '2025-03-26', '2024-11-05')
INSTRUCTIONS = """FreeVideo generates videos (MiniMax H3) on this computer's GPU.
-Start with freevideo_status and freevideo_capabilities. Picture editing, storyboards (submit_batch), editor operations and plan are not listed in full: freevideo_tool_help {name} gives the schema, freevideo_call runs them. Write prompts as the guide says (freevideo_prompt_guide): English sections
+Start with freevideo_status and freevideo_capabilities. Picture editing, storyboards (submit_batch), editor operations and plan are not listed in full: freevideo_tool_help {name} gives the schema, freevideo_call runs them. If a parameter is unclear, read the schema or check with freevideo_plan; do not submit a test job. Write prompts as the guide says (freevideo_prompt_guide): English sections
integrated_multimodal_description / overall_soundscape / non_diegetic_music, [Shot N] cuts, (S1) [Language] ...
dialogue. Generation uses continuous chains and takes minutes: submit, then call freevideo_wait (up to 45 seconds; it returns as soon as the job ends)
or freevideo_job. Interrupted/failed/cancelled generation can continue with freevideo_resume; completed blocks stay.
diff --git a/freevideo_engine/agent/projects.py b/freevideo_engine/agent/projects.py
index d12a4842..a5dd96a6 100644
--- a/freevideo_engine/agent/projects.py
+++ b/freevideo_engine/agent/projects.py
@@ -529,6 +529,30 @@ def voice_replace(self, identity, value, *, jobs, ref=None, client=None):
"""Queue the new voice for the lines the caller changed."""
return self._voice_job('voice_replace', 'voice', identity, value, jobs=jobs, ref=ref, client=client)
+ def voice_say(self, identity, value, *, jobs, ref=None, client=None):
+ """Queue a new line said in the voice of a sound clip of this project; the page places what it makes."""
+ schema.validate(schema.project_schema('voice_say'), value)
+ if not value['text'].strip():
+ raise _invalid('/text', 'something to say', value['text'])
+ with self.listen_lock:
+ digest, replay = self._receipt('projects:voice_say', dict(id=identity, body=value), ref, client)
+ if replay is not None:
+ return replay
+ stored = self._load(identity)['project']
+ if stored['revision'] != value['revision']:
+ raise ProjectError('project_changed', project=stored)
+ reference = value['voice']['reference']
+ clip = _clips(stored).get(reference['clip_id'])
+ if clip is None or not clip.get('source'):
+ raise _invalid('/voice/reference/clip_id', 'a sound clip of this project with media', reference['clip_id'])
+ if 'end' in reference and reference['end'] <= reference.get('start', 0):
+ raise _invalid('/voice/reference/end', 'a time after the start', reference['end'])
+ job = jobs.submit(dict(kind='voice_say', editor_project=identity,
+ **{k: v for k, v in value.items() if k != 'ref'}), client=client)[0]
+ result = dict(job=jobs.view(job), jobs=[jobs.view(job)])
+ self._remember(ref, digest, result, client)
+ return result
+
def voice_estimate(self, identity, value, *, ref=None, client=None):
"""Seconds the changed lines take to make; asked at once, nothing is stored."""
from . import voice
diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py
index b2bd33ec..b61b593e 100644
--- a/freevideo_engine/agent/routes.py
+++ b/freevideo_engine/agent/routes.py
@@ -172,7 +172,7 @@ def submit_batch(jobs, body, client):
return jobs.submit_request(body, client=client)[0]
-JOB_OPERATIONS = ('generate', 'export', 'listen', 'music', 'stems', 'finish_sound', 'voice_lines', 'voice_replace')
+JOB_OPERATIONS = ('generate', 'export', 'listen', 'music', 'stems', 'finish_sound', 'voice_lines', 'voice_replace', 'voice_say')
def stems_supported(machine):
@@ -269,6 +269,7 @@ def capabilities(port, jobs):
'finish_sound': 'Finish the sound of a joined cut: one score, even loudness, room tone',
'voice_lines': 'Write down the spoken lines of a sound clip with their start and end',
'voice': 'Say edited lines again in the original voice and put them back where the originals were',
+ 'voice_say': 'Say a new line in the voice of a sound clip, as long as a given stretch',
'image_edit': 'Render a saved revision of an image session to a picture'},
limits=dict(width=[256, 4096, 32], height=[256, 4096, 32], seconds=[1.625, 60 if continuation else BLOCK_SECONDS],
seconds_tested=[4, 15],
@@ -872,6 +873,14 @@ def activity_item(row, *, card=False):
progress = view.get('progress') or {}
value['progress'] = {k: progress[k] for k in ('phase', 'step', 'fraction', 'remaining_seconds',
'queue_position') + (('block',) if card else ()) if progress.get(k) is not None}
+ if 'fraction' not in value['progress'] and view.get('kind') in ('export', 'listen'):
+ # An export or a subtitle job reports its share of the work as a small JSON detail.
+ try:
+ share = float(json.loads(progress.get('detail') or '{}').get('fraction'))
+ except (TypeError, ValueError, AttributeError):
+ share = None
+ if share is not None and 0 <= share <= 1:
+ value['progress']['fraction'] = share
if view.get('waiting_for'):
value['waiting_for'] = len(view['waiting_for'])
if view.get('error'):
diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py
index 52f1375a..fd6f1631 100644
--- a/freevideo_engine/agent/schema.py
+++ b/freevideo_engine/agent/schema.py
@@ -408,6 +408,15 @@ def finish_sound(*, local=False):
('id', 'text', 'original_text')), 1, 200,
description='Every line of the selection; with start and end on the lines, each changed line also gets the room it has.'),
limit=number(0, 36000, description='The end of the selection in seconds, where the last line may reach; with the lines\' start and end.'))
+VOICE_SAY_FIELDS = dict(
+ text=string(2000, description='What is said.'),
+ seconds=number(.5, 60, description='How long the line is to last: the length of the empty stretch it goes on.'),
+ voice=obj(dict(reference=obj(dict(clip_id=string(64), start=number(0, 36000), end=number(0, 36000),
+ text=string(1000, description='What the stretch says; recognised when omitted.')),
+ ('clip_id',), description='The sound clip, or part of it, whose voice says the line: a speaker of the film, an imported or a recorded voice. Seconds from the clip start.')),
+ ('reference',)),
+ language=VOICE_LANGUAGE,
+ seed=number(0, 2**31 - 1, integer=True, description='Same seed and text, same voice.'))
def voice_lines(*, local=False):
@@ -426,6 +435,14 @@ def voice(*, local=False):
**{'x-job-kind': 'voice'})
+def voice_say(*, local=False):
+ return obj(dict(kind={'const': 'voice_say', 'type': 'string'},
+ editor_project=string(34, pattern=r'^e-[0-9a-f]{32}$'), revision=number(1, 2**53 - 1, integer=True),
+ **VOICE_SAY_FIELDS, **common()), ('kind', 'editor_project', 'revision', 'text', 'seconds', 'voice'),
+ description='Say a new line in the voice of a sound clip, as long as the stretch it goes on; nothing is placed.',
+ **{'x-job-kind': 'voice_say'})
+
+
def image_edit(*, local=False):
return obj(dict(kind={'const': 'image_edit', 'type': 'string'},
image=dict(IMAGE, description='The image session to render (POST /images).'),
@@ -437,12 +454,12 @@ def image_edit(*, local=False):
KINDS = dict(generate=generate, assemble=assemble, vlm=vlm, enhance=enhance, image=image,
export=export, listen=listen, music=music, stems=stems, finish_sound=finish_sound, voice_lines=voice_lines, voice=voice,
- image_edit=image_edit)
+ voice_say=voice_say, image_edit=image_edit)
def job_schema(*, local=False, refs=False, batch=False):
# Music scores a joined cut, so it is not a storyboard row.
- value = {'oneOf': [f(local=local) for kind, f in KINDS.items() if not (batch and kind in ('music', 'stems', 'finish_sound', 'voice_lines', 'voice'))], 'x-discriminator': 'kind',
+ value = {'oneOf': [f(local=local) for kind, f in KINDS.items() if not (batch and kind in ('music', 'stems', 'finish_sound', 'voice_lines', 'voice', 'voice_say'))], 'x-discriminator': 'kind',
'description': 'x-discriminator selects field diagnostics by kind; all accepted branches use standard oneOf.'}
return batch_references(value) if refs else value
@@ -559,7 +576,7 @@ def route(summary, body=None, query=None, **kw):
if method == 'GET':
extra['query'] = shape
else:
- extra.update(body=shape, idempotent=True, **({'status': 201} if name in ('create', 'generate', 'export', 'listen', 'music', 'stems', 'finish_sound', 'voice_lines', 'voice_replace') else {}))
+ extra.update(body=shape, idempotent=True, **({'status': 201} if name in ('create', 'generate', 'export', 'listen', 'music', 'stems', 'finish_sound', 'voice_lines', 'voice_replace', 'voice_say') else {}))
routes[method, suffix] = route(summary, **extra)
for name, (method, suffix, summary) in WHITEBOX_OPERATIONS.items():
extra = dict(path=obj({'id': WHITEBOX}, ('id',))) if '{id}' in suffix else {}
@@ -640,7 +657,8 @@ def route(summary, body=None, query=None, **kw):
for key in (('POST', '/projects/{id}/stems'), ('POST', '/projects/{id}/stems/export'),
('GET', '/jobs/{id}'), ('POST', '/jobs/{id}/cancel'), ('GET', ''),
('POST', '/projects/{id}/voice/lines'), ('POST', '/projects/{id}/voice/replace'),
- ('POST', '/projects/{id}/voice/shorten'), ('POST', '/projects/{id}/voice/estimate')):
+ ('POST', '/projects/{id}/voice/shorten'), ('POST', '/projects/{id}/voice/estimate'),
+ ('POST', '/projects/{id}/voice/say')):
routes[key]['page_ok'] = True
return routes
@@ -967,6 +985,7 @@ def image_schema(name):
'stems_export': ('POST', '/projects/{id}/stems/export', 'Save the separated dialogue, music and ambience of a split clip, with its original mix, as four wav files in the saved sounds. First get the revision and the split clip\'s ID; the answer lists the files at once.'),
'voice_lines': ('POST', '/projects/{id}/voice/lines', 'Write down the lines of a selected sound clip, each with its start and end. First get the revision and clip ID; wait for the job, then read result.lines. Nothing is saved.'),
'voice_replace': ('POST', '/projects/{id}/voice/replace', 'Say the lines you changed again in the original voice and put them where the originals were; unchanged lines keep their sound. Send the lines voice_lines gave, with text changed; wait for the job, then read result.lines for where each new line starts and ends.'),
+ 'voice_say': ('POST', '/projects/{id}/voice/say', 'Say a new line in the voice of a sound clip, as long as the empty stretch it is for. Wait for the job; result.source is the new sound and result.seconds its length. Nothing is placed by the job: the editor page lays it on the stretch.'),
'voice_shorten': ('POST', '/projects/{id}/voice/shorten', 'Shorten one line to fit a number of seconds. The answer is a text; nothing is saved or spoken.'),
'voice_estimate': ('POST', '/projects/{id}/voice/estimate', 'Estimate the seconds the changed lines take to make. The answer comes at once.'),
'music': ('POST', '/projects/{id}/music', 'Compose music as long as the joined cut and lay it on sound track a2 at frame 0, under the video sound. First get the revision; a2 must be empty. Wait for the returned job; the first use downloads the model (20 GB).'),
@@ -978,7 +997,7 @@ def image_schema(name):
listen='freevideo_project_listen', music='freevideo_project_music', finish_sound='freevideo_project_finish_sound',
stems='freevideo_project_stems', stems_export='freevideo_project_stems_export',
voice_lines='freevideo_project_voice_lines', voice_replace='freevideo_project_voice_replace',
- voice_shorten='freevideo_project_voice_shorten', voice_estimate='freevideo_project_voice_estimate')
+ voice_say='freevideo_project_voice_say', voice_shorten='freevideo_project_voice_shorten', voice_estimate='freevideo_project_voice_estimate')
SESSION_OPERATIONS = {
'create': ('POST', '/sessions', 'Create a work conversation for messages and plans shared with the user.'),
@@ -1107,6 +1126,8 @@ def project_schema(name):
return obj(dict(revision=revision, **VOICE_LINES_FIELDS, ref=REF), ('revision', 'selection'))
if name == 'voice_replace':
return obj(dict(revision=revision, **VOICE_FIELDS, ref=REF), ('revision', 'selection', 'lines', 'recognition'))
+ if name == 'voice_say':
+ return obj(dict(revision=revision, **VOICE_SAY_FIELDS, ref=REF), ('revision', 'text', 'seconds', 'voice'))
if name == 'voice_shorten':
return obj(dict(revision=revision, **VOICE_SHORTEN_FIELDS, ref=REF), ('revision', 'line', 'target_seconds'))
if name == 'voice_estimate':
diff --git a/freevideo_engine/agent/spec.py b/freevideo_engine/agent/spec.py
index 241a161c..2220fee2 100644
--- a/freevideo_engine/agent/spec.py
+++ b/freevideo_engine/agent/spec.py
@@ -313,6 +313,10 @@ def normalize(value, *, port=None):
lines=[dict(line) for line in value['lines']], language=value.get('language', 'auto'),
voice=dict(value.get('voice') or {}), lay=value.get('lay', True),
**{k: value[k] for k in ('seed',) if k in value})
+ elif kind == 'voice_say':
+ result.update(editor_project=value['editor_project'], revision=value['revision'], text=value['text'],
+ seconds=float(value['seconds']), voice=dict(reference=dict(value['voice']['reference'])),
+ language=value.get('language', 'auto'), **{k: value[k] for k in ('seed',) if k in value})
elif kind == 'image_edit':
result.update(image=value['image'], revision=value['revision'])
else:
diff --git a/freevideo_engine/agent/voice.py b/freevideo_engine/agent/voice.py
index 9cc6533a..23010e3e 100644
--- a/freevideo_engine/agent/voice.py
+++ b/freevideo_engine/agent/voice.py
@@ -189,11 +189,16 @@ def context(jobs, spec):
if clip['source']['kind'] == 'generated' and str(clip['source']['id']).startswith('freevideo-dubbing/'):
raise JobError('invalid_request', 'A written line is dubbed again from its text, not by changing its voice')
parent, dialogue = clip, None
- if track.get('stem') == 'dialogue':
+ if track.get('stem') == 'dialogue' or track.get('id') == 'a3':
dialogue = clip
- if clip.get('stem_of') not in found:
+ if clip.get('stem_of') is None:
+ # a line said on the voice track by itself (voice_say), or anything on the recording track, is a voice of
+ # its own, split from nothing
+ parent = clip
+ elif clip.get('stem_of') not in found:
raise JobError('invalid_request', 'The voice part has lost the clip it was split from')
- parent = found[clip['stem_of']][1]
+ else:
+ parent = found[clip['stem_of']][1]
else:
for other, candidate in found.values():
if (other.get('stem') == 'dialogue' and candidate.get('stem_of') == clip['id']
@@ -377,6 +382,155 @@ def _seed(spec, lines):
return int.from_bytes(hashlib.sha256(text.encode()).digest()[:4], 'big') % (2 ** 30)
+# ---------------------------------------------------------------------------------------------- voice_say
+
+REFERENCE_SECONDS = 20. # the longest stretch of the voice clip taken as the voice of a new line
+SAY_LEAD = .1 # quiet before a new line starts, at most (a twentieth of the line at most)
+SAY_TRAIL = .15 # quiet after it ends, at most
+
+
+def _voice_clip(jobs, spec):
+ """The clip whose voice says the line: (path of its sound, first and last file seconds of the stretch)."""
+ from ..editor_export import resolve_source
+ ref = spec['voice']['reference']
+ try:
+ project = jobs.projects.get(spec['editor_project'])['project']
+ except FileNotFoundError as error:
+ raise JobError('not_found', 'The project does not exist') from error
+ clip = next((clip for track in _tracks(project) for clip in track['clips'] if clip['id'] == ref['clip_id']), None)
+ if clip is None or not clip.get('source'):
+ raise JobError('invalid_request', 'The voice clip is not a sound clip of this project')
+ if (clip.get('speed') or 1) != 1 or clip.get('reverse'):
+ raise JobError('invalid_request', 'The voice clip must be played at normal speed, forwards')
+ length = clip['frames'] / FPS
+ start, end = float(ref.get('start', 0.)), min(float(ref.get('end', length)), length)
+ if not 0 <= start < end:
+ raise JobError('invalid_request', 'The voice stretch must lie inside the clip')
+ try:
+ path = resolve_source('%s:%s' % (clip['source']['kind'], clip['source']['id']))['audio']
+ except (OSError, ValueError):
+ path = None
+ if path is None:
+ raise JobError('invalid_request', 'The voice clip has no sound')
+ wav = Path(path).with_suffix('.wav')
+ base = clip.get('in', 0) / FPS
+ return (wav if wav.is_file() else Path(path)), base + start, base + end
+
+
+def _spoken(lines, longest=REFERENCE_SECONDS):
+ """The run of consecutive lines that lasts longest without passing `longest` seconds (a single longer line is
+ taken alone; the dubbing cuts it): (start, end, text), or None when there is no line."""
+ best = None
+ for first in range(len(lines)):
+ for last in range(first, len(lines)):
+ span = lines[last]['end'] - lines[first]['start']
+ if last > first and span > longest:
+ break
+ if best is None or span > best[1] - best[0]:
+ best = (lines[first]['start'], lines[last]['end'], first, last)
+ if best is None:
+ return None
+ texts = [line['text'].strip() for line in lines[best[2]:best[3] + 1]]
+ wide = any(re.search(r'[-ヿ㐀-鿿가-]', text) for text in texts)
+ return best[0], best[1], ('' if wide else ' ').join(texts)
+
+
+def _recognise(row, path, first, last, language):
+ """What the stretch [first, last) of a file says: the longest run of its lines, in file seconds."""
+ from .. import subtitle_ears
+ from .. import subtitle_sherpa # noqa: F401 registers the recogniser
+ from ..voice import sentences
+ try:
+ engine = subtitle_ears.engine_for('transcribe')
+ except subtitle_ears.EarsError as error:
+ raise JobError('no_speech_model', 'No speech model is installed on this machine') from error
+ _phase(row, 'recognizing', fraction=0.)
+ try:
+ whole = subtitle_ears.decode_mono(path, cancelled=lambda: bool(row.get('_cancel')))
+ rate = subtitle_ears.RATE
+ audio = np.ascontiguousarray(whole[int(first * rate):int(last * rate)])
+ if len(audio) < rate:
+ raise JobError('no_speech', 'The voice clip is too short to take a voice from')
+ heard = engine.transcribe(audio, language, lambda fraction: _phase(row, 'recognizing', fraction=round(float(fraction), 3)),
+ lambda: bool(row.get('_cancel')))
+ except subtitle_ears.Cancelled as error:
+ raise JobError('cancelled', 'Cancelled') from error
+ except subtitle_ears.EarsError as error:
+ raise JobError(RECOGNITION_CODES.get(error.reason, 'voice_failed'),
+ 'The voice clip could not be recognised (%s)' % error.reason) from error
+ words = [word for line in heard['lines'] for word in line['words']]
+ found = _spoken(sentences.lines(words, audio, rate))
+ if found is None:
+ raise JobError('no_speech', 'No speech was found in the voice clip')
+ return first + found[0], first + found[1], found[2]
+
+
+def run_voice_say(jobs, row, *, poll=.25):
+ """A new line in the voice of a sound clip, `seconds` long: written to a file the page places itself.
+
+ The voiced part runs from just after the start to just before the end; a text too long to say in that time even
+ at the fastest tempo comes back longer, with `overrun` seconds and warning too_long, and one too short to fill it
+ ends early with warning too_short."""
+ from ..dubbing import align
+ from ..stems.separate import write_mp4, write_wav
+ started = time.monotonic()
+ spec = row['spec']
+ text, seconds = spec['text'].strip(), float(spec['seconds'])
+ if not text:
+ raise JobError('invalid_request', 'There is nothing to say')
+ path, first, last = _voice_clip(jobs, spec)
+ known = (spec['voice']['reference'].get('text') or '').strip()
+ recognised = False
+ if known:
+ reference = dict(file=str(path), text=known, start=first, end=last)
+ else:
+ begin, end, heard = _recognise(row, path, first, last, spec.get('language', 'auto'))
+ reference = dict(file=str(path), text=heard, start=begin, end=end)
+ recognised = True
+ _cancelled(row)
+ if reference['end'] - reference['start'] < 1.:
+ raise JobError('no_speech', 'The voice clip is too short to take a voice from')
+ # the line comes back as loud as the voice it takes, measured on its voiced part
+ level = None
+ try:
+ sample = _read(path, reference['start'], reference['end'])
+ span = align.voiced_span(sample, RATE)
+ if span is not None and span[1] > span[0]:
+ level = round(float(np.sqrt(np.mean(np.square(sample[span[0]:span[1]], dtype=np.float64)))), 6) or None
+ except (OSError, ValueError):
+ level = None
+ lead, trail = min(SAY_LEAD, seconds / 20), min(SAY_TRAIL, seconds / 20)
+ item = dict(key='say', text=text, seconds=round(seconds, 3), onset=round(lead, 3), offset=round(seconds - trail, 3),
+ **({'level': level} if level else {}))
+ seed = spec['seed'] if spec.get('seed') is not None else \
+ int.from_bytes(hashlib.sha256('|'.join([spec['editor_project'], reference['file'], str(reference['start']), text])
+ .encode()).digest()[:4], 'big') % (2 ** 30)
+ pace = Pace([dict(id='say', text=text, original_text='')])
+ made, cancelled = _round(jobs, row, [item], reference, seed, pace, 0, 1, [], poll)
+ made = made.get('say')
+ if cancelled or made is None:
+ raise JobError('cancelled', 'Cancelled')
+ if made.get('code') or 'data' not in made:
+ code = made.get('code') or 'failed'
+ raise JobError(DUB_CODES.get(code, 'voice_failed'), 'The line could not be said (%s)' % code)
+ _phase(row, 'placing')
+ data = np.asarray(made['data'], dtype=np.float32)
+ stereo = np.stack([data, data])
+ folder = '%s/%s-%s' % (FOLDER, row['id'], secrets.token_hex(2))
+ out = jobs.port.output_path(folder + '/line.mp4', create=True).parent
+ write_wav(out / 'line.wav', stereo, exact=True)
+ write_mp4(out / 'line.mp4', stereo)
+ length = len(data) / RATE
+ overrun = round(max(float(made.get('overrun') or 0.), length - seconds, 0.), 3)
+ warning = made.get('warning') or ('too_long' if overrun > align.FIT_SECONDS else None)
+ return dict(editor_project=spec['editor_project'], revision=spec['revision'], source='generated:%s/line.mp4' % folder,
+ seconds=round(length, 3), frames=max(1, int(length * FPS + 1e-6)), target_seconds=round(seconds, 3),
+ overrun=overrun if warning == 'too_long' else 0., warning=warning, fitted=bool(made.get('fitted')),
+ onset=made.get('onset'), offset=made.get('offset'), speed=made.get('speed'), stretch=made.get('stretch'),
+ text=text, reference=dict(text=reference['text'], recognised=recognised),
+ seed=seed, seconds_taken=round(time.monotonic() - started, 1))
+
+
class Pace:
"""Remaining seconds of the job: the average of the lines done so far, or the estimate before the first."""
def __init__(self, lines):
diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py
index 1dd72393..b45624b6 100644
--- a/freevideo_engine/chain_runner.py
+++ b/freevideo_engine/chain_runner.py
@@ -252,6 +252,9 @@ def _view(self, record, detailed=True, client_id=None):
elapsed += self._elapsed(block)
remembered = self.progress_values.get(record['id'], {})
value['progress'] = deepcopy(remembered | self.progress_messages.get(record['id'], {}))
+ if record['state'] == 'done' and record['output_state'] in ('done', 'none'):
+ # The running fraction stops short of the end until the parts are joined; a finished chain is whole.
+ value['progress'].update(fraction=1., remaining_seconds=0.)
value['last_heartbeat_at'] = record.get('last_heartbeat_at')
value['elapsed_seconds'] = max(elapsed, remembered.get('elapsed_seconds', 0.))
value['joining'] = record['output_state'] == 'joining'
diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py
index f862d6b3..a2b69231 100644
--- a/freevideo_engine/comfy_editor.py
+++ b/freevideo_engine/comfy_editor.py
@@ -756,7 +756,8 @@ def sources(root, keys):
media['preview'] = True
if source['kind'] == 'import':
metadata = _read_import(root, source['id'])
- media.update(preview_width=media['width'], preview_height=media['height'])
+ # The file's own name, for the page to call an imported sound by.
+ media.update(preview_width=media['width'], preview_height=media['height'], name=metadata['name'])
if metadata.get('original_size'):
media.update(width=metadata['width'], height=metadata['height'])
address = (_view(relative) if relative else '/freevideo/editor/media?' +
diff --git a/freevideo_engine/comfy_host.py b/freevideo_engine/comfy_host.py
index 770643ce..ddd1c6d4 100644
--- a/freevideo_engine/comfy_host.py
+++ b/freevideo_engine/comfy_host.py
@@ -13,6 +13,7 @@
from . import network, processes
from .comfy_bridge import installation
from .comfy_environment import isolated_environment
+from .comfy_requirements import managed_requirements
from .environments import uv_file_arguments
from .locking import runtime_lock
from .monitoring import save
@@ -154,8 +155,9 @@ def git_run(label, command, env):
requirements = comfy / 'requirements.txt'
if not requirements.is_file():
raise ValueError('ComfyUI requirements.txt is missing')
- # An environment is reused only for this exact ComfyUI dependency contract.
- identity = dict(comfy=str(comfy), requirements=hashlib.sha256(requirements.read_bytes()).hexdigest(),
+ requirements, content = managed_requirements(requirements, directory)
+ # Installation, repair, and reuse all use the same filtered contract.
+ identity = dict(comfy=str(comfy), requirements=hashlib.sha256(content).hexdigest(),
engine_python=machine['python'], python=sys.version.split()[0], nodes=list(NODE_PACKAGES))
key = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()[:16]
env_root = root / 'envs' / ('comfyui-' + key)
diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py
index d1ffbe06..265b9080 100644
--- a/freevideo_engine/comfy_launcher_runtime.py
+++ b/freevideo_engine/comfy_launcher_runtime.py
@@ -209,6 +209,7 @@ def host_repair_pending(python):
def _managed_environment(engine, comfy):
"""(python, recorded identity) of our previously completed frontend, or None."""
+ from .comfy_requirements import requirements_content
engine, comfy = io_path(engine), io_path(comfy)
try:
record = json.loads((engine / 'launcher/comfy-host.json').read_text(encoding='utf-8'))
@@ -216,7 +217,7 @@ def _managed_environment(engine, comfy):
environment, python = io_path(record['environment']), io_path(record['python'])
machine = json.loads((engine / 'machine.json').read_text(encoding='utf-8'))
if (identity['comfy'] != plain(comfy) or identity['engine_python'] != machine['python']
- or identity['requirements'] != hashlib.sha256((comfy / 'requirements.txt').read_bytes()).hexdigest()
+ or identity['requirements'] != hashlib.sha256(requirements_content(comfy / 'requirements.txt')).hexdigest()
or environment.resolve().parent != (engine / 'envs').resolve()
or python != environment / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python')
or json.loads((environment / 'freevideo-host.json').read_text(encoding='utf-8')) != identity
@@ -1480,6 +1481,12 @@ def _connect_comfy(self):
command = [selected['python'], '-u', '-B', str(Path(selected['root']) / 'main.py'), '--listen', ','.join(listen_hosts(parsed.hostname)),
'--port', str(parsed.port or 80), '--disable-auto-launch']
managed = managed_frontend(selected)
+ from .comfy_logging import MANAGED_HOST_ENV
+ # Set only for our host Python, including service launches through this Controller.
+ # An inherited marker must never silence logs in a user's own ComfyUI.
+ env.pop(MANAGED_HOST_ENV, None)
+ if managed:
+ env[MANAGED_HOST_ENV] = '1'
# Only the ComfyUI this launcher downloaded loads FreeVideo alone. The user's own ComfyUI keeps
# every plugin, unless they stopped FreeVideo from loading (retry below, or remembered).
ours = launcher_downloaded(selected['root'])
diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py
index f0f0002c..25f1aad5 100644
--- a/freevideo_engine/comfy_library.py
+++ b/freevideo_engine/comfy_library.py
@@ -228,6 +228,10 @@ def list_videos(output_directory, *, before=None, limit=24, favorite=None):
except (OSError, ValueError):
continue
rows, unreadable, folders, reports = [], [], {}, {}
+ from .comfy_progress import REPORTS
+ tokens = REPORTS.tokens(root)
+ if tokens:
+ REPORTS.roots.add(root / 'FreeVideo') # a token of an earlier service stays readable
for key, path, info in sorted(candidates, key=lambda item: item[0], reverse=True):
relative = Path('FreeVideo') / key[1] / 'video.mp4'
if relative.as_posix() in hidden:
@@ -265,6 +269,8 @@ def list_videos(output_directory, *, before=None, limit=24, favorite=None):
agent = None
if agent:
summary['agent'] = agent
+ if key[1] + '/video.mp4' in tokens:
+ summary['report_id'] = tokens[key[1] + '/video.mp4']
folders[key[1]], reports[key[1]] = path.parent, report
rows.append(dict(summary, id=key[1], bytes=info.st_size, favorite=is_favorite,
created_at=datetime.fromtimestamp(info.st_mtime, timezone.utc).isoformat(),
diff --git a/freevideo_engine/comfy_logging.py b/freevideo_engine/comfy_logging.py
new file mode 100644
index 00000000..e5eb11af
--- /dev/null
+++ b/freevideo_engine/comfy_logging.py
@@ -0,0 +1,24 @@
+"""Logging policy for the ComfyUI host launched in our managed environment."""
+import logging
+import os
+
+MANAGED_HOST_ENV = 'FREEVIDEO_MANAGED_COMFYUI'
+
+
+class _OptionalTemplatesFilter(logging.Filter):
+ def filter(self, record):
+ return 'comfyui-workflow-templates is not installed.' not in record.getMessage()
+
+
+_templates_filter = _OptionalTemplatesFilter()
+
+
+def configure_managed_logging():
+ """Hide upstream's reinstall advice for the template library we intentionally omit.
+
+ Upstream uses logging.error on the root logger for both template lookup paths.
+ Filtering there keeps this one record out of every handler, including diagnostics.
+ User-owned ComfyUI instances keep all their logging behavior.
+ """
+ if os.environ.get(MANAGED_HOST_ENV) == '1':
+ logging.getLogger().addFilter(_templates_filter)
diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py
index bffdce0b..a8625776 100644
--- a/freevideo_engine/comfy_nodes.py
+++ b/freevideo_engine/comfy_nodes.py
@@ -394,6 +394,10 @@ def kept(exported):
if 'continuation' in preview['freevideo_summary'][0] and options.get('frames'):
preview['freevideo_summary'][0]['continuation']['requested_frames'] = options['frames']
preview['freevideo_summary'][0]['result_cache_hit'] = result_reused[0]
+ from .comfy_progress import REPORTS
+ known = REPORTS.tokens(output_root).get(relative.as_posix().removeprefix('FreeVideo/'))
+ if known:
+ preview['freevideo_summary'][0]['report_id'] = known
except BaseException as error:
from .resident_process import OWNER
OWNER.finished()
diff --git a/freevideo_engine/comfy_progress.py b/freevideo_engine/comfy_progress.py
index e1226ea5..cf68c96a 100644
--- a/freevideo_engine/comfy_progress.py
+++ b/freevideo_engine/comfy_progress.py
@@ -249,6 +249,19 @@ def _remember(self, token, output):
except Exception:
pass # Reports stay readable in this process; the index is only a convenience.
+ @staticmethod
+ def tokens(output_directory):
+ """Report token of each saved video, keyed by its path below