@remixmate/cli 0.9.56 → 0.9.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/handlers/gen-digital-human.js +27 -25
- package/dist/manifest.json +16 -38
- package/package.json +1 -1
- package/skills/export-jianying/skill.json +5 -6
- package/skills/export-jianying/version.json +1 -1
- package/skills/gen-digital-human/SKILL.md +56 -147
- package/skills/gen-digital-human/skill.json +8 -28
- package/skills/gen-digital-human/version.json +2 -2
- package/skills/prepare-video-assets/version.json +1 -1
- package/skills/render-video/scripts/_digital_human_segments.py +204 -0
- package/skills/render-video/scripts/render_video.py +171 -8
- package/skills/render-video/version.json +1 -1
|
@@ -18,15 +18,20 @@ import { getCapabilities } from '../capabilities.js';
|
|
|
18
18
|
import { emitProgress } from '../progress.js';
|
|
19
19
|
import { isTrue, toNumber } from './shared.js';
|
|
20
20
|
const CLONE_TYPES = ['video', 'image'];
|
|
21
|
+
/**
|
|
22
|
+
* The only provider currently wired end to end (HiFly). HiFly's generate APIs
|
|
23
|
+
* take no aspect ratio or action prompt — the output keeps the cloned source's
|
|
24
|
+
* framing — so the handler sends neither.
|
|
25
|
+
*/
|
|
26
|
+
const ACTIVE_SOURCE = 'hifly';
|
|
21
27
|
function toId(value) {
|
|
22
28
|
if (value === undefined || value === null)
|
|
23
29
|
return '';
|
|
24
30
|
return String(value).trim();
|
|
25
31
|
}
|
|
26
32
|
async function listAvatars(ctx, input) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
payload.source = String(input.source);
|
|
33
|
+
// Only avatars of the active provider can be driven, so only those are listed.
|
|
34
|
+
const payload = { current: 1, pageSize: 100, source: ACTIVE_SOURCE };
|
|
30
35
|
if (input.gender)
|
|
31
36
|
payload.gender = String(input.gender);
|
|
32
37
|
if (isTrue(input.mine))
|
|
@@ -48,23 +53,26 @@ async function listAvatars(ctx, input) {
|
|
|
48
53
|
for (const a of avatars) {
|
|
49
54
|
process.stdout.write(`${toId(a.id).padEnd(24)} ${String(a.name ?? '').padEnd(16)} ${String(a.source ?? '').padEnd(14)} ${String(a.gender ?? '').padEnd(8)} ${String(a.aspectRatio ?? '').padEnd(6)} ${a.cloneStatus || '-'}\n`);
|
|
50
55
|
}
|
|
51
|
-
|
|
56
|
+
const total = data.pager?.total ?? avatars.length;
|
|
57
|
+
process.stdout.write(`\n${avatars.length} avatar(s) shown${total > avatars.length ? ` of ${total}; narrow with --name` : ''}\n`);
|
|
52
58
|
process.stdout.write('💡 Use --avatar-id <ID> to pick an avatar for generation\n');
|
|
53
59
|
}
|
|
54
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Look up one avatar's metadata. Only the caller's own avatars are searched: they
|
|
62
|
+
* are few, and they are the only ones that can be mid-clone or failed. The public
|
|
63
|
+
* HiFly catalog has ~1000 entries, far beyond one page, so a public id that is not
|
|
64
|
+
* among "mine" is passed through as-is and the backend validates it (unknown ids
|
|
65
|
+
* come back as a business error).
|
|
66
|
+
*/
|
|
55
67
|
async function fetchAvatarInfo(ctx, avatarId) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (found)
|
|
60
|
-
return found;
|
|
61
|
-
}
|
|
62
|
-
throw new SkillError(`❌ avatar_id=${avatarId} not found; use --list-avatars [--mine] to see what is available`);
|
|
68
|
+
const data = await mmPost(ctx, '/digital-human/avatar/page', { current: 1, pageSize: 100, mine: true, source: ACTIVE_SOURCE }, { timeoutMs: 30_000 });
|
|
69
|
+
const found = (data.list ?? []).find((a) => toId(a.id) === avatarId);
|
|
70
|
+
return found ?? { id: avatarId, source: ACTIVE_SOURCE };
|
|
63
71
|
}
|
|
64
|
-
function resolveSource(
|
|
65
|
-
const src = (
|
|
72
|
+
function resolveSource(avatar, validSources) {
|
|
73
|
+
const src = (avatar.source ?? '').trim() || ACTIVE_SOURCE;
|
|
66
74
|
if (!validSources.includes(src)) {
|
|
67
|
-
throw new SkillError(`❌
|
|
75
|
+
throw new SkillError(`❌ avatar uses provider "${src}", which is not available (supported: ${validSources.join('/')}). Pick another avatar from --list-avatars.`);
|
|
68
76
|
}
|
|
69
77
|
return src;
|
|
70
78
|
}
|
|
@@ -86,8 +94,6 @@ async function cloneAvatar(ctx, input) {
|
|
|
86
94
|
payload.thumbnailUrl = toId(input.thumbnail_url);
|
|
87
95
|
if (input.gender)
|
|
88
96
|
payload.gender = toId(input.gender);
|
|
89
|
-
if (input.aspect_ratio)
|
|
90
|
-
payload.aspectRatio = toId(input.aspect_ratio);
|
|
91
97
|
emitProgress({ phase: 'gen-digital-human:clone-request', sourceType });
|
|
92
98
|
const data = await mmPost(ctx, '/digital-human/avatar/clone', payload, { timeoutMs: 60_000 });
|
|
93
99
|
const avatarId = toId(data.id);
|
|
@@ -210,6 +216,9 @@ export async function genDigitalHuman(input, ctxIn) {
|
|
|
210
216
|
}
|
|
211
217
|
const audioUrl = (input.audio_url ?? '').trim();
|
|
212
218
|
const text = input.text ?? '';
|
|
219
|
+
if (audioUrl && !/^https?:\/\//i.test(audioUrl)) {
|
|
220
|
+
throw new SkillError(`❌ --audio-url needs a public http(s) URL (local files are not uploaded), received: ${audioUrl}`);
|
|
221
|
+
}
|
|
213
222
|
if (!audioUrl) {
|
|
214
223
|
if (!text.trim()) {
|
|
215
224
|
throw new SkillError('❌ in TTS mode provide --text, or use --audio-url for audio-driven mode');
|
|
@@ -224,12 +233,10 @@ export async function genDigitalHuman(input, ctxIn) {
|
|
|
224
233
|
if (avatar.cloneStatus === 'failed') {
|
|
225
234
|
throw new SkillError(`❌ avatar ${avatarId} failed to clone (${avatar.cloneError || 'unknown error'}); clone it again`);
|
|
226
235
|
}
|
|
227
|
-
const source = resolveSource(
|
|
236
|
+
const source = resolveSource(avatar, dh.sources);
|
|
228
237
|
const payload = { avatarId, source };
|
|
229
238
|
if (audioUrl) {
|
|
230
239
|
payload.audioUrl = audioUrl;
|
|
231
|
-
if (text.trim())
|
|
232
|
-
payload.text = text.trim();
|
|
233
240
|
}
|
|
234
241
|
else {
|
|
235
242
|
payload.text = text;
|
|
@@ -237,11 +244,6 @@ export async function genDigitalHuman(input, ctxIn) {
|
|
|
237
244
|
if (input.voice_name)
|
|
238
245
|
payload.voiceName = String(input.voice_name);
|
|
239
246
|
}
|
|
240
|
-
const aspectRatio = input.aspect_ratio ?? avatar.aspectRatio;
|
|
241
|
-
if (aspectRatio)
|
|
242
|
-
payload.aspectRatio = aspectRatio;
|
|
243
|
-
if (input.prompt)
|
|
244
|
-
payload.prompt = String(input.prompt);
|
|
245
247
|
emitProgress({ phase: 'gen-digital-human:request', avatarId, source, mode: audioUrl ? 'audio' : 'tts' });
|
|
246
248
|
const data = await mmPost(ctx, '/digital-human/video/generate', payload, { timeoutMs: 60_000 });
|
|
247
249
|
const generationId = data.generationId;
|
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"version": "0.9.58",
|
|
4
|
+
"generatedAt": "2026-09-29T10:20:23.290Z",
|
|
5
5
|
"skills": [
|
|
6
6
|
{
|
|
7
7
|
"id": "doc-parse",
|
|
@@ -147,15 +147,14 @@
|
|
|
147
147
|
},
|
|
148
148
|
"ui": {
|
|
149
149
|
"primary": [
|
|
150
|
-
"
|
|
150
|
+
"system"
|
|
151
151
|
],
|
|
152
|
-
"advanced": [
|
|
153
|
-
|
|
152
|
+
"advanced": [],
|
|
153
|
+
"hidden": [
|
|
154
|
+
"title",
|
|
154
155
|
"width",
|
|
155
156
|
"height",
|
|
156
|
-
"output"
|
|
157
|
-
],
|
|
158
|
-
"hidden": [
|
|
157
|
+
"output",
|
|
159
158
|
"from_render_plan",
|
|
160
159
|
"scenes",
|
|
161
160
|
"draft_root_path",
|
|
@@ -169,13 +168,12 @@
|
|
|
169
168
|
"tier": "atomic",
|
|
170
169
|
"category": "asset",
|
|
171
170
|
"title": "Digital-Human Talking-Head",
|
|
172
|
-
"summary": "Digital-human video: list
|
|
171
|
+
"summary": "Digital-human video (HiFly): list or clone avatars; produce a talking-head video from text via TTS, or drive an avatar from an existing audio URL. Aspect ratio follows the avatar's cloned source and cannot be chosen. Talking-head videos cost credits per second of output (5 s minimum); fails with insufficient_credits when the balance is too low.",
|
|
173
172
|
"triggers": [
|
|
174
173
|
"Digital human, talking-head video, AI presenter, virtual host",
|
|
175
174
|
"Make a talking-head video out of this script / this audio file",
|
|
176
175
|
"List or browse digital-human avatars",
|
|
177
|
-
"
|
|
178
|
-
"Clone / create a custom avatar from a video or photo (HiFly)"
|
|
176
|
+
"Clone / create a custom avatar from a video or photo"
|
|
179
177
|
],
|
|
180
178
|
"entry": {
|
|
181
179
|
"type": "http",
|
|
@@ -202,14 +200,6 @@
|
|
|
202
200
|
"type": "string",
|
|
203
201
|
"description": "With list_avatars=true: fuzzy-filter avatars by name. With clone=true: the new avatar's name (required)."
|
|
204
202
|
},
|
|
205
|
-
"source": {
|
|
206
|
-
"type": "string",
|
|
207
|
-
"enum": [
|
|
208
|
-
"jimeng",
|
|
209
|
-
"hifly"
|
|
210
|
-
],
|
|
211
|
-
"description": "Provider filter / override. hifly avatars (including cloned custom avatars) are video-driven; jimeng is image-driven. Usually inferred from the avatar; pass it explicitly only when the avatar declares no source."
|
|
212
|
-
},
|
|
213
203
|
"gender": {
|
|
214
204
|
"type": "string",
|
|
215
205
|
"enum": [
|
|
@@ -224,7 +214,7 @@
|
|
|
224
214
|
},
|
|
225
215
|
"clone": {
|
|
226
216
|
"type": "boolean",
|
|
227
|
-
"description": "Clone mode: create a custom HiFly avatar from a video (free) or a portrait image (costs
|
|
217
|
+
"description": "Clone mode: create a custom HiFly avatar from a video (free) or a portrait image (costs credits per clone, refunded automatically if cloning fails; see the pricing page). Requires clone_url and name. Waits until cloning finishes unless wait=false."
|
|
228
218
|
},
|
|
229
219
|
"clone_url": {
|
|
230
220
|
"type": "string",
|
|
@@ -240,7 +230,7 @@
|
|
|
240
230
|
},
|
|
241
231
|
"thumbnail_url": {
|
|
242
232
|
"type": "string",
|
|
243
|
-
"description": "With clone=true: optional cover image URL shown in avatar lists (image clones default to the image itself)."
|
|
233
|
+
"description": "With clone=true: optional cover image URL shown in avatar lists (image clones default to the image itself). The output aspect ratio always follows the cloned source; it cannot be chosen at generation time."
|
|
244
234
|
},
|
|
245
235
|
"wait": {
|
|
246
236
|
"type": "boolean",
|
|
@@ -252,28 +242,20 @@
|
|
|
252
242
|
},
|
|
253
243
|
"text": {
|
|
254
244
|
"type": "string",
|
|
255
|
-
"description": "Narration text (TTS mode)"
|
|
245
|
+
"description": "Narration text (TTS mode, ≤ 10000 chars). Synthesized with voice_id, then drives the avatar; the speech must last at least 5 s. Ignored when audio_url is given."
|
|
256
246
|
},
|
|
257
247
|
"audio_url": {
|
|
258
248
|
"type": "string",
|
|
259
|
-
"description": "Audio
|
|
249
|
+
"description": "Audio-driven mode: public http(s) URL of mp3 / m4a / wav, 5 s – 30 min, ≤ 100 MB. Takes precedence over text / voice_id."
|
|
260
250
|
},
|
|
261
251
|
"voice_id": {
|
|
262
252
|
"type": "string",
|
|
263
|
-
"description": "Voice id (TTS mode). Shares the
|
|
253
|
+
"description": "Voice id (TTS mode). Shares the catalog with gen-voice (including cloned voices) — call gen_voice with list_voices=true to see available ids rather than inventing one."
|
|
264
254
|
},
|
|
265
255
|
"voice_name": {
|
|
266
256
|
"type": "string",
|
|
267
257
|
"description": "Voice display name, recorded alongside voice_id for bookkeeping. Does not affect synthesis."
|
|
268
258
|
},
|
|
269
|
-
"aspect_ratio": {
|
|
270
|
-
"type": "string",
|
|
271
|
-
"description": "Aspect ratio: 9:16 / 16:9 / 3:4 / 1:1. Defaults to the avatar's own ratio."
|
|
272
|
-
},
|
|
273
|
-
"prompt": {
|
|
274
|
-
"type": "string",
|
|
275
|
-
"description": "Action prompt describing how the avatar should perform, e.g. 'more hand gestures'"
|
|
276
|
-
},
|
|
277
259
|
"check_status": {
|
|
278
260
|
"type": "boolean",
|
|
279
261
|
"description": "Status-check mode: poll an earlier job instead of starting a new one. Requires generation_id. Use this when a generate call timed out."
|
|
@@ -292,15 +274,11 @@
|
|
|
292
274
|
"ui": {
|
|
293
275
|
"primary": [
|
|
294
276
|
"avatar_id",
|
|
295
|
-
"text"
|
|
296
|
-
],
|
|
297
|
-
"advanced": [
|
|
298
|
-
"source",
|
|
277
|
+
"text",
|
|
299
278
|
"voice_id",
|
|
300
|
-
"aspect_ratio",
|
|
301
|
-
"prompt",
|
|
302
279
|
"audio_url"
|
|
303
280
|
],
|
|
281
|
+
"advanced": [],
|
|
304
282
|
"hidden": [
|
|
305
283
|
"json_output",
|
|
306
284
|
"list_avatars",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.58",
|
|
4
4
|
"description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -60,15 +60,14 @@
|
|
|
60
60
|
},
|
|
61
61
|
"ui": {
|
|
62
62
|
"primary": [
|
|
63
|
-
"
|
|
63
|
+
"system"
|
|
64
64
|
],
|
|
65
|
-
"advanced": [
|
|
66
|
-
|
|
65
|
+
"advanced": [],
|
|
66
|
+
"hidden": [
|
|
67
|
+
"title",
|
|
67
68
|
"width",
|
|
68
69
|
"height",
|
|
69
|
-
"output"
|
|
70
|
-
],
|
|
71
|
-
"hidden": [
|
|
70
|
+
"output",
|
|
72
71
|
"from_render_plan",
|
|
73
72
|
"scenes",
|
|
74
73
|
"draft_root_path",
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "export-jianying",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "554",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V4",
|
|
6
6
|
"skillDescription": "Jianying (CapCut) draft-generation skill. Packages asset URLs into a draft ZIP that Jianying can import; supports automatic conversion from a RenderPlan (calls ab-api /file/generateJianYing).\n\nUse this skill as soon as the user mentions any of these intents:\n- Export to Jianying, Jianying draft, package for Jianying, import into Jianying\n- Export materials to the Jianying format, generate a Jianying project\n- Bundle video / image / audio into a Jianying draft\n- Export a Jianying draft from a RenderPlan\n\nEven when the user does not say \"Jianying\" explicitly, use this skill whenever they want to package materials into a draft that can be edited in Jianying."
|
|
7
7
|
}
|
|
@@ -1,117 +1,78 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gen-digital-human
|
|
3
3
|
description: |
|
|
4
|
-
Digital-human (talking-head) skill: list
|
|
4
|
+
Digital-human (talking-head) skill backed by HiFly: list avatars, clone a custom avatar from a video or photo, drive an avatar with text (TTS) or an existing audio URL, and check the status of pending jobs.
|
|
5
5
|
|
|
6
6
|
Use this skill immediately whenever the user asks for any of:
|
|
7
7
|
- Digital human, talking-head video, AI presenter, virtual host
|
|
8
8
|
- "Make a talking-head video out of this script / this audio file"
|
|
9
9
|
- List or browse digital-human avatars
|
|
10
|
-
-
|
|
11
|
-
- Clone / create a custom avatar from a video or photo (HiFly)
|
|
10
|
+
- Clone / create a custom avatar from a video or photo
|
|
12
11
|
|
|
13
12
|
Even without an explicit "use AI", any request that turns text or audio into a synthesized presenter video should route here.
|
|
14
13
|
triggers:
|
|
15
14
|
- Digital human, talking-head video, AI presenter, virtual host
|
|
16
15
|
- "Make a talking-head video out of this script / this audio file"
|
|
17
16
|
- List or browse digital-human avatars
|
|
18
|
-
-
|
|
19
|
-
- Clone / create a custom avatar from a video or photo (HiFly)
|
|
17
|
+
- Clone / create a custom avatar from a video or photo
|
|
20
18
|
---
|
|
21
19
|
|
|
22
20
|
# Digital-Human Skill
|
|
23
21
|
|
|
24
|
-
Wraps ab-api's digital-human endpoints (the same ones the web studio uses), authenticated with the **Tianyan privateToken**.
|
|
25
|
-
|
|
26
|
-
> This skill was migrated from a Python script to an remixmate CLI HTTP handler (`entry.type: http`). The agent invocation is unchanged (same tool name `gen_digital_human`, same params as in `skill.json`); local repro goes through `remixmate gen-digital-human ...`.
|
|
22
|
+
Wraps ab-api's digital-human endpoints (the same ones the web studio uses), authenticated with the **Tianyan privateToken**. The provider is **HiFly**: avatars are cloned from a video or photo, then driven by audio. Generation is async — the handler submits the task and polls `/digital-human/video/status` until completion.
|
|
27
23
|
|
|
28
24
|
## Capabilities
|
|
29
25
|
|
|
30
26
|
| Capability | Description | API |
|
|
31
27
|
|------------|-------------|-----|
|
|
32
|
-
| List avatars |
|
|
33
|
-
|
|
|
28
|
+
| List avatars | Public avatars, or your own with `--mine` | `POST /digital-human/avatar/page` |
|
|
29
|
+
| Text-driven | avatar + text + voice → speech is synthesized, then drives the avatar | `POST /digital-human/video/generate` |
|
|
34
30
|
| Audio-driven | avatar + existing audio URL → talking-head video | `POST /digital-human/video/generate` |
|
|
35
|
-
| Check status | Poll
|
|
36
|
-
| Clone avatar |
|
|
37
|
-
| Clone status |
|
|
38
|
-
|
|
39
|
-
Avatar ids are opaque strings (e.g. `dh_xxx`) — always copy them from `--list-avatars`, never invent or renumber them.
|
|
31
|
+
| Check status | Poll a generation job | `POST /digital-human/video/status` |
|
|
32
|
+
| Clone avatar | Custom avatar from a video (free) or portrait photo (charged per clone, refunded on failure) | `POST /digital-human/avatar/clone` |
|
|
33
|
+
| Clone status | `cloning` / `done` / `failed` for a cloned avatar | `POST /digital-human/avatar/clone-status` |
|
|
40
34
|
|
|
41
|
-
|
|
35
|
+
Avatar ids are opaque strings (e.g. `dh_xxx`) — always copy them from `--list-avatars`, never invent them.
|
|
42
36
|
|
|
43
|
-
|
|
37
|
+
**What you cannot choose**: aspect ratio and on-camera actions. The output keeps the framing of the source the avatar was cloned from (a 9:16 clone produces 9:16 videos). To get another ratio, clone the avatar from footage in that ratio.
|
|
44
38
|
|
|
45
|
-
|
|
39
|
+
**Cross-skill dependency**: voice ids are shared with `gen-voice` (including cloned voices). Use `gen-voice --list-voices` to see them.
|
|
46
40
|
|
|
47
|
-
|
|
48
|
-
- **Other environments**: configure the token. Without a token, non-interactive runs fail.
|
|
41
|
+
## Auth & environment
|
|
49
42
|
|
|
50
43
|
| Env var | Description | Default |
|
|
51
44
|
|---------|-------------|---------|
|
|
52
|
-
| `PRIV_TOKEN` | Tianyan token; `--priv-token` overrides | none |
|
|
45
|
+
| `PRIV_TOKEN` | Tianyan token; `--priv-token` overrides (not needed in Enterprise OpenClaw) | none |
|
|
53
46
|
| `MM_API_BASE_URL` | API root; `--api-base-url` overrides | `https://api.remixmate.ai/api` |
|
|
54
47
|
| `AGENT_NAME` | Optional `x-invoke-agent` header | none |
|
|
55
48
|
|
|
56
49
|
## Operations
|
|
57
50
|
|
|
58
|
-
|
|
51
|
+
Generation typically takes 1–5 minutes; the handler polls and emits `__progress__` lines. The resulting video URL is cloud-persisted — show it directly, no download needed.
|
|
59
52
|
|
|
60
53
|
### 1. List avatars
|
|
61
54
|
|
|
62
55
|
```bash
|
|
63
|
-
remixmate gen-digital-human --list-avatars
|
|
56
|
+
remixmate gen-digital-human --list-avatars --name "教师" # public avatars (~1000; always narrow with --name)
|
|
57
|
+
remixmate gen-digital-human --list-avatars --mine # your cloned avatars
|
|
58
|
+
remixmate gen-digital-human --list-avatars --gender female --name "alice"
|
|
64
59
|
```
|
|
65
60
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
# Filter by source (jimeng = image-driven, hifly = video-driven)
|
|
70
|
-
remixmate gen-digital-human --list-avatars --source jimeng
|
|
71
|
-
remixmate gen-digital-human --list-avatars --source hifly
|
|
72
|
-
|
|
73
|
-
# Filter by gender
|
|
74
|
-
remixmate gen-digital-human --list-avatars --gender female
|
|
75
|
-
|
|
76
|
-
# Show your custom avatars
|
|
77
|
-
remixmate gen-digital-human --list-avatars --mine
|
|
78
|
-
|
|
79
|
-
# Filter by name
|
|
80
|
-
remixmate gen-digital-human --list-avatars --name "alice"
|
|
81
|
-
```
|
|
61
|
+
### 2. Generate a video
|
|
82
62
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
### 2. Generate a digital-human video
|
|
86
|
-
|
|
87
|
-
#### TTS mode
|
|
63
|
+
Pick exactly one driver: **text** (+ voice) or **audio**. With `--audio-url`, text and voice are ignored.
|
|
88
64
|
|
|
89
65
|
```bash
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
--text "Hi everyone,
|
|
66
|
+
# Text-driven
|
|
67
|
+
remixmate gen-digital-human --avatar-id dh_xxx \
|
|
68
|
+
--text "Hi everyone, today I'll introduce a new product." \
|
|
93
69
|
--voice-id "male-qn-qingse"
|
|
94
|
-
```
|
|
95
70
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
```bash
|
|
99
|
-
remixmate gen-digital-human \
|
|
100
|
-
--avatar-id dh_xxx \
|
|
101
|
-
--text "Hi class, here's our brand-new course." \
|
|
102
|
-
--voice-id "female-shaonv" \
|
|
103
|
-
--aspect-ratio "9:16"
|
|
71
|
+
# Audio-driven
|
|
72
|
+
remixmate gen-digital-human --avatar-id dh_xxx --audio-url "https://example.com/voice.mp3"
|
|
104
73
|
```
|
|
105
74
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
remixmate gen-digital-human \
|
|
110
|
-
--avatar-id dh_yyy \
|
|
111
|
-
--source hifly \
|
|
112
|
-
--audio-url "https://example.com/voice.mp3" \
|
|
113
|
-
--aspect-ratio "16:9"
|
|
114
|
-
```
|
|
75
|
+
Limits: speech/audio must last **5 s – 30 min**; audio is mp3 / m4a / wav, ≤ 100 MB, public http(s) URL; text ≤ 10000 characters.
|
|
115
76
|
|
|
116
77
|
### 3. Check job status
|
|
117
78
|
|
|
@@ -119,7 +80,7 @@ remixmate gen-digital-human \
|
|
|
119
80
|
remixmate gen-digital-human --check-status --generation-id 123
|
|
120
81
|
```
|
|
121
82
|
|
|
122
|
-
### 4. Clone a custom avatar
|
|
83
|
+
### 4. Clone a custom avatar
|
|
123
84
|
|
|
124
85
|
The source must be a public http(s) URL (local files are not uploaded). Video: mp4/mov (h264), 5 s – 30 min, 360p – 4K, ≤ 500 MB, one person speaking to camera. Cloning takes minutes; the handler waits (up to ~20 min) and prints the new avatar id. The avatar also shows up under 素材库 → 数字人 in the web app.
|
|
125
86
|
|
|
@@ -127,7 +88,7 @@ The source must be a public http(s) URL (local files are not uploaded). Video: m
|
|
|
127
88
|
# Video clone (free)
|
|
128
89
|
remixmate gen-digital-human --clone --name "My presenter" --clone-url "https://example.com/me.mp4"
|
|
129
90
|
|
|
130
|
-
# Portrait
|
|
91
|
+
# Portrait photo clone (charged per clone)
|
|
131
92
|
remixmate gen-digital-human --clone --clone-type image --name "My presenter" --clone-url "https://example.com/me.jpg"
|
|
132
93
|
|
|
133
94
|
# Submit without waiting, then poll
|
|
@@ -135,86 +96,35 @@ remixmate gen-digital-human --clone --wait false --name "My presenter" --clone-u
|
|
|
135
96
|
remixmate gen-digital-human --clone-status --avatar-id dh_xxx
|
|
136
97
|
```
|
|
137
98
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
|
145
|
-
|
|
146
|
-
| `--
|
|
147
|
-
| `--
|
|
148
|
-
| `--
|
|
149
|
-
| `--
|
|
150
|
-
| `--
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
|
155
|
-
|
|
156
|
-
| `--
|
|
157
|
-
|
|
|
158
|
-
| `--audio-url` | Audio URL (audio-driven mode) | none |
|
|
159
|
-
| `--voice-id` | Voice id (TTS mode) | `male-qn-qingse` |
|
|
160
|
-
| `--voice-name` | Voice display name (recordkeeping) | none |
|
|
161
|
-
| `--source` | `jimeng` / `hifly` (inferable from avatar) | auto |
|
|
162
|
-
| `--aspect-ratio` | `9:16` / `16:9` / `3:4` / `1:1` | avatar's default |
|
|
163
|
-
| `--prompt` | Action prompt (e.g. "more hand gestures") | none |
|
|
164
|
-
| `--api-base-url` | Override API root | see env vars |
|
|
165
|
-
| `--priv-token` | Override token | see env vars |
|
|
166
|
-
|
|
167
|
-
### Check status
|
|
168
|
-
|
|
169
|
-
| Flag | Description | Default |
|
|
170
|
-
|------|-------------|---------|
|
|
171
|
-
| `--check-status` | Enter status-check mode | — |
|
|
172
|
-
| `--generation-id` | Job id (required) | — |
|
|
173
|
-
|
|
174
|
-
### Clone avatar
|
|
175
|
-
|
|
176
|
-
| Flag | Description | Default |
|
|
177
|
-
|------|-------------|---------|
|
|
178
|
-
| `--clone` | Enter clone mode | — |
|
|
179
|
-
| `--name` | New avatar name (required) | — |
|
|
180
|
-
| `--clone-url` | Public URL of the source video / image (required) | — |
|
|
181
|
-
| `--clone-type` | `video` / `image` | `video` |
|
|
182
|
-
| `--thumbnail-url` | Cover image shown in lists | image itself / none |
|
|
183
|
-
| `--gender` | `male` / `female` | none |
|
|
184
|
-
| `--wait` | `false` to return right after submission | wait |
|
|
185
|
-
| `--clone-status` | Report clone status of `--avatar-id` | — |
|
|
186
|
-
|
|
187
|
-
## Typical flows
|
|
188
|
-
|
|
189
|
-
### Flow A — TTS
|
|
190
|
-
|
|
191
|
-
```
|
|
192
|
-
1. List avatars → --list-avatars [--gender female]
|
|
193
|
-
2. Pick avatar-id
|
|
194
|
-
3. Confirm or polish the speech text
|
|
195
|
-
4. (Optional) pick a voice → use gen-voice --list-voices
|
|
196
|
-
5. Generate → --avatar-id <id> --text "..." --voice-id "female-shaonv"
|
|
197
|
-
6. Show URL
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
### Flow B — Audio-driven
|
|
99
|
+
## Flags
|
|
100
|
+
|
|
101
|
+
| Flag | Mode | Description | Default |
|
|
102
|
+
|------|------|-------------|---------|
|
|
103
|
+
| `--list-avatars` | list | List avatars and exit | — |
|
|
104
|
+
| `--mine` | list | Only your own avatars | off |
|
|
105
|
+
| `--gender` | list / clone | `male` / `female` | none |
|
|
106
|
+
| `--name` | list / clone | Fuzzy filter; with `--clone` the new avatar's name (required) | — |
|
|
107
|
+
| `--avatar-id` | generate / clone-status | Avatar id (required) | — |
|
|
108
|
+
| `-t` / `--text` | generate | Script (text-driven) | — |
|
|
109
|
+
| `--voice-id` | generate | Voice id (text-driven) | `male-qn-qingse` |
|
|
110
|
+
| `--voice-name` | generate | Voice display name (recordkeeping only) | none |
|
|
111
|
+
| `--audio-url` | generate | Audio URL (audio-driven) | none |
|
|
112
|
+
| `--check-status` / `--generation-id` | status | Poll a generation job | — |
|
|
113
|
+
| `--clone` / `--clone-url` | clone | Clone mode and source URL (required) | — |
|
|
114
|
+
| `--clone-type` | clone | `video` / `image` | `video` |
|
|
115
|
+
| `--thumbnail-url` | clone | Cover shown in lists | image itself / none |
|
|
116
|
+
| `--wait` | clone | `false` to return right after submission | wait |
|
|
117
|
+
| `--clone-status` | clone-status | Report clone status of `--avatar-id` | — |
|
|
118
|
+
| `--api-base-url` / `--priv-token` | all | Overrides | see env vars |
|
|
201
119
|
|
|
202
|
-
|
|
203
|
-
1. List avatars → --list-avatars [--mine]
|
|
204
|
-
2. Prepare an audio URL (gen-voice can produce one)
|
|
205
|
-
3. Generate → --avatar-id <id> --audio-url "https://..." [--source hifly]
|
|
206
|
-
4. Show URL
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
## Text guidance (TTS)
|
|
120
|
+
## Credits
|
|
210
121
|
|
|
211
|
-
|
|
212
|
-
- Keep individual jobs under ~500 characters.
|
|
213
|
-
- Tone and style of the script affect the perceived voice.
|
|
122
|
+
Photo cloning and video generation charge credits (rates on https://www.remixmate.ai/pricing):
|
|
214
123
|
|
|
215
|
-
|
|
124
|
+
- **Clone**: video clones are free. Photo clones cost a flat per-clone fee, charged at submission and refunded automatically if cloning fails.
|
|
125
|
+
- **Video**: per second of output video (5 s minimum), charged when the video completes. The balance is checked before submission; too low → `insufficient_credits`, nothing is generated.
|
|
216
126
|
|
|
217
|
-
|
|
127
|
+
The CLI prints a footer on stdout when a charge is reported in the response:
|
|
218
128
|
|
|
219
129
|
```
|
|
220
130
|
💳 Charged 31 credits · balance 1,240
|
|
@@ -229,11 +139,10 @@ generation cost, and the balance is the only warning before a run fails with
|
|
|
229
139
|
- **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
|
|
230
140
|
- **Business `code != 0`**: read `msg` on stderr.
|
|
231
141
|
- **429**: rate-limited; retry later.
|
|
232
|
-
- **Generation timeout**:
|
|
233
|
-
- **
|
|
142
|
+
- **Generation timeout**: polling caps at ~10 minutes; use `--check-status --generation-id <id>` later.
|
|
143
|
+
- **Speech/audio under 5 s**: lengthen the text or audio.
|
|
234
144
|
- **Invalid voice id**: re-check via `gen-voice --list-voices`.
|
|
235
145
|
- **Avatar id not found**: re-check via `--list-avatars` (or `--list-avatars --mine`).
|
|
236
146
|
- **Avatar still cloning / clone failed**: poll `--clone-status --avatar-id <id>`; on failure, re-clone with a clearer source (single speaker facing the camera, h264 mp4).
|
|
237
|
-
- **
|
|
238
|
-
- **Cannot infer source**: when the avatar lacks a `source` and `--source` is not set, pass `--source jimeng` or `--source hifly` explicitly.
|
|
147
|
+
- **User asks for a different aspect ratio or gestures**: not supported at generation time — explain that the ratio follows the cloned source, and offer to clone from footage in the wanted ratio.
|
|
239
148
|
- **Network**: verify connectivity and `MM_API_BASE_URL`.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"tier": "atomic",
|
|
5
5
|
"category": "asset",
|
|
6
6
|
"title": "Digital-Human Talking-Head",
|
|
7
|
-
"description": "Digital-human video: list
|
|
7
|
+
"description": "Digital-human video (HiFly): list or clone avatars; produce a talking-head video from text via TTS, or drive an avatar from an existing audio URL. Aspect ratio follows the avatar's cloned source and cannot be chosen. Talking-head videos cost credits per second of output (5 s minimum); fails with insufficient_credits when the balance is too low.",
|
|
8
8
|
"auth": "required",
|
|
9
9
|
"joinsTake": true,
|
|
10
10
|
"envVars": [
|
|
@@ -31,14 +31,6 @@
|
|
|
31
31
|
"type": "string",
|
|
32
32
|
"description": "With list_avatars=true: fuzzy-filter avatars by name. With clone=true: the new avatar's name (required)."
|
|
33
33
|
},
|
|
34
|
-
"source": {
|
|
35
|
-
"type": "string",
|
|
36
|
-
"enum": [
|
|
37
|
-
"jimeng",
|
|
38
|
-
"hifly"
|
|
39
|
-
],
|
|
40
|
-
"description": "Provider filter / override. hifly avatars (including cloned custom avatars) are video-driven; jimeng is image-driven. Usually inferred from the avatar; pass it explicitly only when the avatar declares no source."
|
|
41
|
-
},
|
|
42
34
|
"gender": {
|
|
43
35
|
"type": "string",
|
|
44
36
|
"enum": [
|
|
@@ -53,7 +45,7 @@
|
|
|
53
45
|
},
|
|
54
46
|
"clone": {
|
|
55
47
|
"type": "boolean",
|
|
56
|
-
"description": "Clone mode: create a custom HiFly avatar from a video (free) or a portrait image (costs
|
|
48
|
+
"description": "Clone mode: create a custom HiFly avatar from a video (free) or a portrait image (costs credits per clone, refunded automatically if cloning fails; see the pricing page). Requires clone_url and name. Waits until cloning finishes unless wait=false."
|
|
57
49
|
},
|
|
58
50
|
"clone_url": {
|
|
59
51
|
"type": "string",
|
|
@@ -69,7 +61,7 @@
|
|
|
69
61
|
},
|
|
70
62
|
"thumbnail_url": {
|
|
71
63
|
"type": "string",
|
|
72
|
-
"description": "With clone=true: optional cover image URL shown in avatar lists (image clones default to the image itself)."
|
|
64
|
+
"description": "With clone=true: optional cover image URL shown in avatar lists (image clones default to the image itself). The output aspect ratio always follows the cloned source; it cannot be chosen at generation time."
|
|
73
65
|
},
|
|
74
66
|
"wait": {
|
|
75
67
|
"type": "boolean",
|
|
@@ -81,28 +73,20 @@
|
|
|
81
73
|
},
|
|
82
74
|
"text": {
|
|
83
75
|
"type": "string",
|
|
84
|
-
"description": "Narration text (TTS mode)"
|
|
76
|
+
"description": "Narration text (TTS mode, ≤ 10000 chars). Synthesized with voice_id, then drives the avatar; the speech must last at least 5 s. Ignored when audio_url is given."
|
|
85
77
|
},
|
|
86
78
|
"audio_url": {
|
|
87
79
|
"type": "string",
|
|
88
|
-
"description": "Audio
|
|
80
|
+
"description": "Audio-driven mode: public http(s) URL of mp3 / m4a / wav, 5 s – 30 min, ≤ 100 MB. Takes precedence over text / voice_id."
|
|
89
81
|
},
|
|
90
82
|
"voice_id": {
|
|
91
83
|
"type": "string",
|
|
92
|
-
"description": "Voice id (TTS mode). Shares the
|
|
84
|
+
"description": "Voice id (TTS mode). Shares the catalog with gen-voice (including cloned voices) — call gen_voice with list_voices=true to see available ids rather than inventing one."
|
|
93
85
|
},
|
|
94
86
|
"voice_name": {
|
|
95
87
|
"type": "string",
|
|
96
88
|
"description": "Voice display name, recorded alongside voice_id for bookkeeping. Does not affect synthesis."
|
|
97
89
|
},
|
|
98
|
-
"aspect_ratio": {
|
|
99
|
-
"type": "string",
|
|
100
|
-
"description": "Aspect ratio: 9:16 / 16:9 / 3:4 / 1:1. Defaults to the avatar's own ratio."
|
|
101
|
-
},
|
|
102
|
-
"prompt": {
|
|
103
|
-
"type": "string",
|
|
104
|
-
"description": "Action prompt describing how the avatar should perform, e.g. 'more hand gestures'"
|
|
105
|
-
},
|
|
106
90
|
"check_status": {
|
|
107
91
|
"type": "boolean",
|
|
108
92
|
"description": "Status-check mode: poll an earlier job instead of starting a new one. Requires generation_id. Use this when a generate call timed out."
|
|
@@ -121,15 +105,11 @@
|
|
|
121
105
|
"ui": {
|
|
122
106
|
"primary": [
|
|
123
107
|
"avatar_id",
|
|
124
|
-
"text"
|
|
125
|
-
],
|
|
126
|
-
"advanced": [
|
|
127
|
-
"source",
|
|
108
|
+
"text",
|
|
128
109
|
"voice_id",
|
|
129
|
-
"aspect_ratio",
|
|
130
|
-
"prompt",
|
|
131
110
|
"audio_url"
|
|
132
111
|
],
|
|
112
|
+
"advanced": [],
|
|
133
113
|
"hidden": [
|
|
134
114
|
"json_output",
|
|
135
115
|
"list_avatars",
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "gen-digital-human",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "336",
|
|
5
|
-
"version": "
|
|
6
|
-
"skillDescription": "Digital-human (talking-head) skill: list
|
|
5
|
+
"version": "V10",
|
|
6
|
+
"skillDescription": "Digital-human (talking-head) skill backed by HiFly: list avatars, clone a custom avatar from a video or photo, drive an avatar with text (TTS) or an existing audio URL, and check the status of pending jobs.\n\nUse this skill immediately whenever the user asks for any of:\n- Digital human, talking-head video, AI presenter, virtual host\n- \"Make a talking-head video out of this script / this audio file\"\n- List or browse digital-human avatars\n- Clone / create a custom avatar from a video or photo\n\nEven without an explicit \"use AI\", any request that turns text or audio into a synthesized presenter video should route here."
|
|
7
7
|
}
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "prepare-video-assets",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "476",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V6",
|
|
6
6
|
"skillDescription": "Asset-preparation skill: resolves and generates every asset (image / audio / video) referenced by a Video DSL, persists a RenderPlan to the database, and returns a `job_id` for the subsequent `render_video` call.\n\nUse this skill as soon as the user mentions any of these intents:\n- Generate / prepare video assets, resolve assets, render-ready\n- \"Make me a video about X\" (the agent calls gen_script → prepare_video_assets → render_video)\n- Regenerate one asset (image / audio) for a specific scene\n\nNext step: after the user confirms the resolved assets, call `render_video` with the `job_id` returned by this skill.\n\n⚠️ Stop-and-confirm gate: this skill runs only after the user has confirmed the script, and after it returns you must show the resolved assets and wait for the user's explicit confirmation. Never call `render_video` in the same turn."
|
|
7
7
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""数字人连续段:分段、驱动音频规划、起播帧注入。
|
|
2
|
+
|
|
3
|
+
一个 ``source=gen-digital-human`` 的 avatar 资产被**相邻**几个场景的
|
|
4
|
+
``props.avatarAssetId`` 同时引用 = 一个连续段:生成一段视频,人物跨场景不重启。
|
|
5
|
+
要让第 k 个场景的第 0 帧正好对上视频里的对应位置,驱动音频必须是「段内各场景
|
|
6
|
+
旁白、每段补静音到场景最终时长」拼成的一条——所以数字人只能在**排轴之后**生成
|
|
7
|
+
(场景时长由真实 TTS 时长决定,见 render_video.adjust_timeline_to_audio)。
|
|
8
|
+
|
|
9
|
+
本模块只放纯函数与 ffmpeg 拼接;调度(何时生成、如何回写 RenderPlan 状态)在
|
|
10
|
+
render_video.resolve_digital_human_segments。全部按 RenderPlan 的通用字段工作,
|
|
11
|
+
不识别具体模板:模板是否支持由 template.json 的 ``capabilities.digitalHuman`` 声明,
|
|
12
|
+
写没写 avatar 资产由上游决定。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
DIGITAL_HUMAN_SOURCE = "gen-digital-human"
|
|
23
|
+
|
|
24
|
+
#: 驱动音频下限。飞影要求 5 秒 ~ 30 分钟;不足时在段尾补静音。
|
|
25
|
+
MIN_DRIVE_SEC = 5.0
|
|
26
|
+
|
|
27
|
+
#: 拼接输出的采样率 / 声道。口型驱动不需要立体声,统一成单声道避免混格式失败。
|
|
28
|
+
_SAMPLE_RATE = 44100
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _entry_props(entry: dict) -> dict:
|
|
32
|
+
props = entry.get("props")
|
|
33
|
+
return props if isinstance(props, dict) else {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def digital_human_asset_ids(render_plan: dict) -> set[str]:
|
|
37
|
+
return {
|
|
38
|
+
a.get("assetId")
|
|
39
|
+
for a in render_plan.get("resolvedAssets", [])
|
|
40
|
+
if a.get("source") == DIGITAL_HUMAN_SOURCE and a.get("assetId")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def collect_segments(render_plan: dict) -> dict[str, list[int]]:
|
|
45
|
+
"""avatar 资产 id → 引用它的 timeline 下标(按出场顺序)。
|
|
46
|
+
|
|
47
|
+
只收 gen-digital-human 资产:``existing`` 的数字人视频是作者自备的,起播点由作者
|
|
48
|
+
自己负责(``digitalHuman.startFromSec``),管线不去猜。
|
|
49
|
+
"""
|
|
50
|
+
dh_ids = digital_human_asset_ids(render_plan)
|
|
51
|
+
segments: dict[str, list[int]] = {}
|
|
52
|
+
for idx, entry in enumerate(render_plan.get("timeline", [])):
|
|
53
|
+
ref = _entry_props(entry).get("avatarAssetId")
|
|
54
|
+
if ref in dh_ids:
|
|
55
|
+
segments.setdefault(ref, []).append(idx)
|
|
56
|
+
return segments
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_contiguous(indices: list[int]) -> bool:
|
|
60
|
+
return all(b == a + 1 for a, b in zip(indices, indices[1:]))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def segment_role(pos: int, count: int) -> str:
|
|
64
|
+
if count == 1:
|
|
65
|
+
return "only"
|
|
66
|
+
if pos == 0:
|
|
67
|
+
return "first"
|
|
68
|
+
return "last" if pos == count - 1 else "middle"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def inject_segment_props(render_plan: dict, segments: dict[str, list[int]]) -> list[str]:
|
|
72
|
+
"""给段内每个场景写 ``avatarOffsetFrames`` / ``avatarSegmentRole``。
|
|
73
|
+
|
|
74
|
+
偏移用 RenderPlan 里最终的 ``durationFrames`` 累加,不按秒重算,避免累计舍入。
|
|
75
|
+
返回告警(不相邻的同一资产:偏移仍按出场顺序累加,但中间隔开的场景会让口型错位)。
|
|
76
|
+
"""
|
|
77
|
+
warnings: list[str] = []
|
|
78
|
+
timeline = render_plan.get("timeline", [])
|
|
79
|
+
for asset_id, indices in segments.items():
|
|
80
|
+
if not is_contiguous(indices):
|
|
81
|
+
scene_ids = [timeline[i].get("sceneId", str(i)) for i in indices]
|
|
82
|
+
warnings.append(
|
|
83
|
+
f"digital-human asset {asset_id} is referenced by non-adjacent scenes "
|
|
84
|
+
f"{scene_ids}; one video can only span adjacent scenes — lip sync will drift"
|
|
85
|
+
)
|
|
86
|
+
offset = 0
|
|
87
|
+
for pos, idx in enumerate(indices):
|
|
88
|
+
entry = timeline[idx]
|
|
89
|
+
props = entry.setdefault("props", {})
|
|
90
|
+
props["avatarOffsetFrames"] = offset
|
|
91
|
+
props["avatarSegmentRole"] = segment_role(pos, len(indices))
|
|
92
|
+
offset += int(entry.get("durationFrames") or 0)
|
|
93
|
+
return warnings
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def plan_drive_pieces(render_plan: dict, indices: list[int]) -> list[dict]:
|
|
97
|
+
"""段内每个场景一块:``{"sceneId", "url" | None, "durationSec"}``。
|
|
98
|
+
|
|
99
|
+
``url`` 取场景旁白资产(``props.narrationAssetId``)已生成的地址;没有旁白的场景
|
|
100
|
+
是整段静音。块长 = 场景最终时长;总长不足 MIN_DRIVE_SEC 时把最后一块拉长。
|
|
101
|
+
"""
|
|
102
|
+
fps = (render_plan.get("renderConfig") or {}).get("fps") or 30
|
|
103
|
+
assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
|
|
104
|
+
timeline = render_plan.get("timeline", [])
|
|
105
|
+
pieces: list[dict] = []
|
|
106
|
+
for idx in indices:
|
|
107
|
+
entry = timeline[idx]
|
|
108
|
+
nar = assets.get(_entry_props(entry).get("narrationAssetId"))
|
|
109
|
+
url = nar.get("url") if nar and nar.get("status") == "generated" else None
|
|
110
|
+
pieces.append({
|
|
111
|
+
"sceneId": entry.get("sceneId", str(idx)),
|
|
112
|
+
"url": url or None,
|
|
113
|
+
"durationSec": round(int(entry.get("durationFrames") or 0) / fps, 3),
|
|
114
|
+
})
|
|
115
|
+
total = sum(p["durationSec"] for p in pieces)
|
|
116
|
+
if pieces and total < MIN_DRIVE_SEC:
|
|
117
|
+
pieces[-1]["durationSec"] = round(pieces[-1]["durationSec"] + MIN_DRIVE_SEC - total, 3)
|
|
118
|
+
return pieces
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def direct_drive_url(render_plan: dict, indices: list[int]) -> Optional[str]:
|
|
122
|
+
"""单场景段、且旁白本身就够长时,直接拿旁白音频驱动,不必拼接。
|
|
123
|
+
|
|
124
|
+
视频比场景短几百毫秒(旁白尾巴)无妨:模板默认 ``onEnd: freeze`` 停在最后一帧。
|
|
125
|
+
"""
|
|
126
|
+
if len(indices) != 1:
|
|
127
|
+
return None
|
|
128
|
+
entry = render_plan.get("timeline", [])[indices[0]]
|
|
129
|
+
assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
|
|
130
|
+
nar = assets.get(_entry_props(entry).get("narrationAssetId"))
|
|
131
|
+
if not nar or nar.get("status") != "generated" or not nar.get("url"):
|
|
132
|
+
return None
|
|
133
|
+
duration_ms = nar.get("duration") or 0
|
|
134
|
+
if duration_ms and duration_ms / 1000.0 >= MIN_DRIVE_SEC:
|
|
135
|
+
return nar["url"]
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def split_segment_per_scene(render_plan: dict, asset_id: str, indices: list[int]) -> list[str]:
|
|
140
|
+
"""把一个连续段拆成每场景一段(拿不到 ffmpeg 拼接时的降级)。
|
|
141
|
+
|
|
142
|
+
人物会在场景边界重新起播,但每段都能直接用本场景旁白驱动,口型不会错位。
|
|
143
|
+
新资产 id = ``<原 id>.<sceneId>``;原资产从 resolvedAssets 移除,场景改指新 id。
|
|
144
|
+
"""
|
|
145
|
+
assets = render_plan.get("resolvedAssets", [])
|
|
146
|
+
original = next((a for a in assets if a.get("assetId") == asset_id), None)
|
|
147
|
+
if original is None:
|
|
148
|
+
return []
|
|
149
|
+
timeline = render_plan.get("timeline", [])
|
|
150
|
+
new_ids: list[str] = []
|
|
151
|
+
for idx in indices:
|
|
152
|
+
entry = timeline[idx]
|
|
153
|
+
new_id = f"{asset_id}.{entry.get('sceneId', idx)}"
|
|
154
|
+
clone = {**original, "assetId": new_id, "status": "pending", "url": "", "splitFrom": asset_id}
|
|
155
|
+
assets.append(clone)
|
|
156
|
+
entry.setdefault("props", {})["avatarAssetId"] = new_id
|
|
157
|
+
new_ids.append(new_id)
|
|
158
|
+
render_plan["resolvedAssets"] = [a for a in assets if a.get("assetId") != asset_id]
|
|
159
|
+
return new_ids
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_concat_command(pieces: list[dict], out_path: str, ffmpeg: str = "ffmpeg") -> list[str]:
|
|
163
|
+
"""ffmpeg 参数:每块重采样成单声道 → 补静音 → 精确截到块长 → 顺序拼接成 mp3。"""
|
|
164
|
+
cmd = [ffmpeg, "-y", "-loglevel", "error"]
|
|
165
|
+
filters: list[str] = []
|
|
166
|
+
labels: list[str] = []
|
|
167
|
+
for i, piece in enumerate(pieces):
|
|
168
|
+
dur = f"{max(piece['durationSec'], 0.01):.3f}"
|
|
169
|
+
if piece.get("url"):
|
|
170
|
+
cmd += ["-i", piece["url"]]
|
|
171
|
+
else:
|
|
172
|
+
cmd += ["-f", "lavfi", "-t", dur, "-i", f"anullsrc=r={_SAMPLE_RATE}:cl=mono"]
|
|
173
|
+
filters.append(
|
|
174
|
+
f"[{i}:a]aresample={_SAMPLE_RATE},aformat=sample_fmts=fltp:channel_layouts=mono,"
|
|
175
|
+
f"apad,atrim=0:{dur},asetpts=N/SR/TB[a{i}]"
|
|
176
|
+
)
|
|
177
|
+
labels.append(f"[a{i}]")
|
|
178
|
+
filters.append(f"{''.join(labels)}concat=n={len(pieces)}:v=0:a=1[out]")
|
|
179
|
+
cmd += [
|
|
180
|
+
"-filter_complex", ";".join(filters),
|
|
181
|
+
"-map", "[out]",
|
|
182
|
+
"-c:a", "libmp3lame", "-b:a", "128k",
|
|
183
|
+
out_path,
|
|
184
|
+
]
|
|
185
|
+
return cmd
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_ffmpeg() -> Optional[str]:
|
|
189
|
+
return shutil.which("ffmpeg")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def concat_drive_audio(pieces: list[dict], out_dir: str, name: str, ffmpeg: str, timeout: int = 300) -> str:
|
|
193
|
+
"""拼出驱动音频,返回本地文件路径。失败抛 RuntimeError。"""
|
|
194
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
195
|
+
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in name)
|
|
196
|
+
out_path = os.path.join(out_dir, f"dh-drive-{safe}.mp3")
|
|
197
|
+
cmd = build_concat_command(pieces, out_path, ffmpeg)
|
|
198
|
+
try:
|
|
199
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
200
|
+
except subprocess.TimeoutExpired as exc:
|
|
201
|
+
raise RuntimeError(f"ffmpeg concat timed out after {timeout}s") from exc
|
|
202
|
+
if result.returncode != 0 or not os.path.exists(out_path):
|
|
203
|
+
raise RuntimeError(f"ffmpeg concat failed: {(result.stderr or '').strip()[:500]}")
|
|
204
|
+
return out_path
|
|
@@ -1198,8 +1198,19 @@ ASSET_RESOLVERS = {
|
|
|
1198
1198
|
}
|
|
1199
1199
|
|
|
1200
1200
|
|
|
1201
|
-
def _resolve_single_asset(
|
|
1202
|
-
|
|
1201
|
+
def _resolve_single_asset(
|
|
1202
|
+
asset: dict,
|
|
1203
|
+
render_plan: dict,
|
|
1204
|
+
private_token: str,
|
|
1205
|
+
max_retries: int,
|
|
1206
|
+
timeout: int,
|
|
1207
|
+
payload_override: Optional[dict] = None,
|
|
1208
|
+
) -> bool:
|
|
1209
|
+
"""Resolve one asset. Returns True if generated, False if failed/skipped.
|
|
1210
|
+
|
|
1211
|
+
``payload_override`` 整份替换资产 payload,且**不回写 DSL**:数字人连续段的驱动
|
|
1212
|
+
音频是按本次排轴现拼的,写回 DSL 的话下次改了旁白还会拿旧音频去驱动。
|
|
1213
|
+
"""
|
|
1203
1214
|
source = asset.get("source", "")
|
|
1204
1215
|
resolver = ASSET_RESOLVERS.get(source)
|
|
1205
1216
|
if not resolver:
|
|
@@ -1218,11 +1229,14 @@ def _resolve_single_asset(asset: dict, render_plan: dict, private_token: str, ma
|
|
|
1218
1229
|
break
|
|
1219
1230
|
|
|
1220
1231
|
asset_with_payload = asset.copy()
|
|
1221
|
-
if
|
|
1232
|
+
if payload_override is not None:
|
|
1233
|
+
asset_with_payload["payload"] = dict(payload_override)
|
|
1234
|
+
elif dsl_asset and "payload" in dsl_asset:
|
|
1222
1235
|
asset_with_payload["payload"] = dsl_asset["payload"]
|
|
1223
1236
|
|
|
1224
|
-
#
|
|
1225
|
-
|
|
1237
|
+
# Legacy:没走连续段调度(见 resolve_digital_human_segments)的数字人资产,
|
|
1238
|
+
# 沿用「取第一个已生成的旁白」—— 只对全片一个数字人成立。
|
|
1239
|
+
if source == "gen-digital-human" and payload_override is None:
|
|
1226
1240
|
payload = asset_with_payload.get("payload", {})
|
|
1227
1241
|
if not payload.get("audioUrl"):
|
|
1228
1242
|
# Find the first generated TTS audio asset and use its URL
|
|
@@ -1319,12 +1333,21 @@ def apply_stub_urls(render_plan: dict, stub_image_url: str = "", stub_video_url:
|
|
|
1319
1333
|
return stubbed
|
|
1320
1334
|
|
|
1321
1335
|
|
|
1322
|
-
def resolve_assets(
|
|
1336
|
+
def resolve_assets(
|
|
1337
|
+
render_plan: dict,
|
|
1338
|
+
private_token: str,
|
|
1339
|
+
max_retries: int,
|
|
1340
|
+
timeout: int,
|
|
1341
|
+
defer_digital_human: bool = False,
|
|
1342
|
+
) -> dict:
|
|
1323
1343
|
"""Resolve all pending assets in the render plan.
|
|
1324
1344
|
|
|
1325
1345
|
Two-phase resolution:
|
|
1326
1346
|
Phase 1: resolve non-avatar assets (TTS, images, video) — **in parallel**
|
|
1327
1347
|
Phase 2: resolve avatar/digital-human assets which may depend on generated audio URLs — serial
|
|
1348
|
+
|
|
1349
|
+
``defer_digital_human=True`` 时跳过 Phase 2:场景引用的数字人要等排轴之后按连续段
|
|
1350
|
+
生成(驱动音频要按场景最终时长补静音),由 resolve_digital_human_segments 接手。
|
|
1328
1351
|
"""
|
|
1329
1352
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
1330
1353
|
|
|
@@ -1340,6 +1363,10 @@ def resolve_assets(render_plan: dict, private_token: str, max_retries: int, time
|
|
|
1340
1363
|
phase1 = [a for a in pending if a.get("source") != "gen-digital-human"]
|
|
1341
1364
|
# Phase 2: resolve digital-human assets (can now use generated audio URLs)
|
|
1342
1365
|
phase2 = [a for a in pending if a.get("source") == "gen-digital-human"]
|
|
1366
|
+
if defer_digital_human:
|
|
1367
|
+
from _digital_human_segments import collect_segments
|
|
1368
|
+
referenced = set(collect_segments(render_plan))
|
|
1369
|
+
phase2 = [a for a in phase2 if a.get("assetId") not in referenced]
|
|
1343
1370
|
|
|
1344
1371
|
generated = 0
|
|
1345
1372
|
failed = 0
|
|
@@ -1390,6 +1417,133 @@ def resolve_assets(render_plan: dict, private_token: str, max_retries: int, time
|
|
|
1390
1417
|
return render_plan
|
|
1391
1418
|
|
|
1392
1419
|
|
|
1420
|
+
def resolve_digital_human_segments(
|
|
1421
|
+
render_plan: dict,
|
|
1422
|
+
private_token: str,
|
|
1423
|
+
max_retries: int,
|
|
1424
|
+
timeout: int,
|
|
1425
|
+
work_dir: Optional[str] = None,
|
|
1426
|
+
) -> dict:
|
|
1427
|
+
"""排轴之后按连续段生成数字人,并给段内场景注入起播帧 / 段角色。
|
|
1428
|
+
|
|
1429
|
+
必须在 adjust_timeline_to_audio 之后调:驱动音频 = 段内各场景旁白、每段补静音
|
|
1430
|
+
到该场景**最终**时长,视频第 Σ前序场景帧 帧才正好是本场景第 0 帧。
|
|
1431
|
+
|
|
1432
|
+
- 单场景段且旁白 ≥ 5 秒:直接用旁白驱动,不拼接。
|
|
1433
|
+
- 其余情况用 ffmpeg 拼一条(ab-agent 镜像自带 ffmpeg),上传后驱动。
|
|
1434
|
+
- 没有 ffmpeg:多场景段拆成每场景一段(人物在场景边界重新起播,但口型不错位),
|
|
1435
|
+
并在 logs 里说明。
|
|
1436
|
+
- 已生成的资产(续跑 / 复用)不重做,只重新注入偏移——偏移只取决于排轴。
|
|
1437
|
+
"""
|
|
1438
|
+
import _digital_human_segments as dhs
|
|
1439
|
+
|
|
1440
|
+
segments = dhs.collect_segments(render_plan)
|
|
1441
|
+
if not segments:
|
|
1442
|
+
return render_plan
|
|
1443
|
+
|
|
1444
|
+
assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
|
|
1445
|
+
dsl_payloads = {
|
|
1446
|
+
a.get("assetId"): dict(a.get("payload") or {})
|
|
1447
|
+
for a in render_plan.get("_dsl_assets", [])
|
|
1448
|
+
if a.get("assetId")
|
|
1449
|
+
}
|
|
1450
|
+
ffmpeg = dhs.find_ffmpeg()
|
|
1451
|
+
out_dir = work_dir or os.path.join(OUTPUT_DIR, "digital-human")
|
|
1452
|
+
failed = 0
|
|
1453
|
+
|
|
1454
|
+
def log(message: str, level: str = "info") -> None:
|
|
1455
|
+
render_plan.setdefault("logs", []).append({
|
|
1456
|
+
"phase": "digital-human",
|
|
1457
|
+
"level": level,
|
|
1458
|
+
"message": message,
|
|
1459
|
+
"timestamp": now_iso(),
|
|
1460
|
+
})
|
|
1461
|
+
LogPrint(f" 🧑💼 {message}", file=sys.stderr)
|
|
1462
|
+
|
|
1463
|
+
# 先决定每段怎么驱动;没有 ffmpeg 时把多场景段拆开,拆完再统一生成。
|
|
1464
|
+
jobs: list[tuple[str, dict]] = [] # (assetId, payload)
|
|
1465
|
+
for asset_id, indices in list(segments.items()):
|
|
1466
|
+
asset = assets.get(asset_id)
|
|
1467
|
+
if not asset or asset.get("status") != "pending":
|
|
1468
|
+
continue
|
|
1469
|
+
base_payload = dsl_payloads.get(asset_id, {})
|
|
1470
|
+
if base_payload.get("audioUrl"):
|
|
1471
|
+
# 作者显式给了驱动音频:尊重它,不再拼接。
|
|
1472
|
+
jobs.append((asset_id, base_payload))
|
|
1473
|
+
continue
|
|
1474
|
+
if len(indices) > 1 and not ffmpeg:
|
|
1475
|
+
new_ids = dhs.split_segment_per_scene(render_plan, asset_id, indices)
|
|
1476
|
+
log(
|
|
1477
|
+
f"ffmpeg not found; split continuous segment {asset_id} into {len(new_ids)} "
|
|
1478
|
+
"per-scene videos (the presenter restarts at each scene boundary)",
|
|
1479
|
+
level="warn",
|
|
1480
|
+
)
|
|
1481
|
+
timeline = render_plan.get("timeline", [])
|
|
1482
|
+
for idx, new_id in zip(indices, new_ids):
|
|
1483
|
+
if not dhs.plan_drive_pieces(render_plan, [idx])[0].get("url"):
|
|
1484
|
+
# 没有旁白的场景单独成段就没有东西可驱动:这一屏不出人,而不是整段失败。
|
|
1485
|
+
timeline[idx].get("props", {}).pop("avatarAssetId", None)
|
|
1486
|
+
render_plan["resolvedAssets"] = [
|
|
1487
|
+
a for a in render_plan["resolvedAssets"] if a.get("assetId") != new_id
|
|
1488
|
+
]
|
|
1489
|
+
log(f"{new_id}: scene has no narration to drive a standalone presenter; skipped", level="warn")
|
|
1490
|
+
continue
|
|
1491
|
+
dsl_payloads[new_id] = base_payload
|
|
1492
|
+
jobs.append((new_id, base_payload))
|
|
1493
|
+
continue
|
|
1494
|
+
jobs.append((asset_id, base_payload))
|
|
1495
|
+
|
|
1496
|
+
segments = dhs.collect_segments(render_plan)
|
|
1497
|
+
assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
|
|
1498
|
+
|
|
1499
|
+
for asset_id, base_payload in jobs:
|
|
1500
|
+
asset = assets.get(asset_id)
|
|
1501
|
+
indices = segments.get(asset_id, [])
|
|
1502
|
+
if not asset or not indices:
|
|
1503
|
+
continue
|
|
1504
|
+
payload = dict(base_payload)
|
|
1505
|
+
if not payload.get("audioUrl"):
|
|
1506
|
+
try:
|
|
1507
|
+
url = dhs.direct_drive_url(render_plan, indices)
|
|
1508
|
+
if not url:
|
|
1509
|
+
pieces = dhs.plan_drive_pieces(render_plan, indices)
|
|
1510
|
+
if not any(p.get("url") for p in pieces):
|
|
1511
|
+
raise RuntimeError("no generated narration audio in this segment to drive the presenter")
|
|
1512
|
+
if not ffmpeg:
|
|
1513
|
+
# 单场景但旁白不足 5 秒,又没法补静音:照样提交,由服务端判定。
|
|
1514
|
+
url = next(p["url"] for p in pieces if p.get("url"))
|
|
1515
|
+
log(f"{asset_id}: narration shorter than {dhs.MIN_DRIVE_SEC:.0f}s and ffmpeg not found; submitting unpadded", level="warn")
|
|
1516
|
+
else:
|
|
1517
|
+
local = dhs.concat_drive_audio(pieces, out_dir, asset_id, ffmpeg)
|
|
1518
|
+
from upload_video import upload_media_file
|
|
1519
|
+
url = upload_media_file(local, title=f"digital-human drive {asset_id}")["fileUrl"]
|
|
1520
|
+
total = sum(p["durationSec"] for p in pieces)
|
|
1521
|
+
log(f"{asset_id}: drive audio for {len(pieces)} scene(s), {total:.1f}s")
|
|
1522
|
+
payload["audioUrl"] = url
|
|
1523
|
+
except Exception as exc: # noqa: BLE001 — 任一段失败不拖垮其它段
|
|
1524
|
+
asset["status"] = "failed"
|
|
1525
|
+
failed += 1
|
|
1526
|
+
render_plan.setdefault("errors", []).append({
|
|
1527
|
+
"phase": "digital-human",
|
|
1528
|
+
"message": f"drive audio failed: {exc}"[:1000],
|
|
1529
|
+
"assetId": asset_id,
|
|
1530
|
+
"timestamp": now_iso(),
|
|
1531
|
+
})
|
|
1532
|
+
log(f"{asset_id}: drive audio failed: {exc}", level="error")
|
|
1533
|
+
continue
|
|
1534
|
+
# 口型音轨就是驱动音频,文本 / 音色字段留着会让服务端改走 TTS 驱动。
|
|
1535
|
+
payload.pop("text", None)
|
|
1536
|
+
payload.pop("voiceId", None)
|
|
1537
|
+
if not _resolve_single_asset(asset, render_plan, private_token, max_retries, timeout, payload_override=payload):
|
|
1538
|
+
failed += 1
|
|
1539
|
+
|
|
1540
|
+
for warning in dhs.inject_segment_props(render_plan, dhs.collect_segments(render_plan)):
|
|
1541
|
+
log(warning, level="warn")
|
|
1542
|
+
if failed:
|
|
1543
|
+
render_plan["status"] = "failed"
|
|
1544
|
+
return render_plan
|
|
1545
|
+
|
|
1546
|
+
|
|
1393
1547
|
def _auto_highlight_map(
|
|
1394
1548
|
entry: dict,
|
|
1395
1549
|
narration_asset: Optional[dict],
|
|
@@ -2658,15 +2812,24 @@ Examples:
|
|
|
2658
2812
|
private_token=private_token,
|
|
2659
2813
|
max_retries=args.max_asset_retries,
|
|
2660
2814
|
timeout=args.asset_timeout,
|
|
2815
|
+
defer_digital_human=True,
|
|
2661
2816
|
)
|
|
2662
2817
|
else:
|
|
2663
2818
|
LogPrint(f"✅ all assets ready", file=sys.stderr)
|
|
2664
2819
|
render_plan["status"] = "assets-ready"
|
|
2665
2820
|
|
|
2666
|
-
render_plan.pop("_dsl_assets", None)
|
|
2667
|
-
|
|
2668
2821
|
if not args.skip_asset_resolve:
|
|
2669
2822
|
render_plan = adjust_timeline_to_audio(render_plan)
|
|
2823
|
+
# 数字人按连续段生成要用到排轴后的场景时长,所以排在 adjust 之后;
|
|
2824
|
+
# 它还要读 DSL 里的资产 payload,_dsl_assets 因此晚一步再丢。
|
|
2825
|
+
render_plan = resolve_digital_human_segments(
|
|
2826
|
+
render_plan,
|
|
2827
|
+
private_token=private_token,
|
|
2828
|
+
max_retries=args.max_asset_retries,
|
|
2829
|
+
timeout=args.asset_timeout,
|
|
2830
|
+
)
|
|
2831
|
+
|
|
2832
|
+
render_plan.pop("_dsl_assets", None)
|
|
2670
2833
|
|
|
2671
2834
|
output_path = os.path.join(OUTPUT_DIR, "video.mp4")
|
|
2672
2835
|
rp_output = args.render_plan_output or os.path.join(OUTPUT_DIR, "render-plan.json")
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "render-video",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "473",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V33",
|
|
6
6
|
"skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
|
|
7
7
|
}
|