@wji.ai/cli 0.7.1 → 0.8.1
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/index.js +6 -0
- package/package.json +8 -9
- package/skill/short-drama/SKILL.md +46 -10
- package/skill/short-drama/reference/bgm-sfx.md +124 -0
- package/skill/short-drama/reference/canvas-recipes.md +19 -2
- package/skill/short-drama/reference/film-mixing-guideline.md +447 -0
- package/skill/short-drama/reference/mix-over-video.md +89 -0
- package/skill/short-drama/reference/operations-cli.md +2 -1
- package/skill/short-drama/reference/schema.md +4 -2
- package/skill/short-drama/reference/stage-characters.md +2 -2
- package/skill/short-drama/reference/stage-storyboard.md +29 -6
- package/skill/short-drama/reference/workspace-layout.md +2 -2
- package/skill/short-drama/scripts/selftest.mjs +88 -10
- package/skill/short-drama/scripts/short-drama-dashboard.mjs +459 -0
- package/skill/short-drama/scripts/short-drama.mjs +174 -12
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
// prompt <project> segment E01-01 derived video-prompt skeleton + keyframe reference plan
|
|
28
28
|
// prompt <project> voice C01 the acted sample text for the voice node
|
|
29
29
|
// prompt <project> audio E01-01 [--full] the segment's Seed Audio soundtrack prompt (timecoded ambience / sfx / music, + lines with --full)
|
|
30
|
+
// prompt <project> score E01 [--fps 24] the episode's spotting sheet for the post soundtrack (audioTrack post): planned
|
|
31
|
+
// timecodes of scenes, lines, music cues and post sfx, and the ≤120 s Seed Audio sections
|
|
32
|
+
// dashboard <project> [--output dashboard.html] [--assets <dir>]
|
|
33
|
+
// static production dashboard for the whole project
|
|
30
34
|
// manifest <project> [--stage …] [--ep N] unbound WJI objects, in execution order
|
|
31
35
|
// bind <project> [--ep N] <path> <value> write a WJI id back (path segments may be entity ids)
|
|
32
36
|
// slug <name>
|
|
@@ -34,6 +38,7 @@
|
|
|
34
38
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
35
39
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
36
40
|
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { renderDashboard } from './short-drama-dashboard.mjs';
|
|
37
42
|
|
|
38
43
|
// ── constants ───────────────────────────────────────────────────────────────
|
|
39
44
|
|
|
@@ -98,8 +103,12 @@ export const DEFAULT_PARAMS = Object.freeze({
|
|
|
98
103
|
speechLeadSeconds: 0.5,
|
|
99
104
|
subtitles: false,
|
|
100
105
|
maxFrameChars: 900,
|
|
101
|
-
//
|
|
102
|
-
|
|
106
|
+
// Where the score lives. `post` (default): the clips carry dialogue, ambience and in-picture sfx but
|
|
107
|
+
// no music — music and sound design cannot carry across separately generated segments, so they are
|
|
108
|
+
// made once over the edited episode, on its timecode. `bed` / `full`: a Seed Audio node per segment.
|
|
109
|
+
audioTrack: 'post',
|
|
110
|
+
// Frame rate the post spotting sheet writes SMPTE timecode at (conform it to the edit's real fps).
|
|
111
|
+
scoreFps: 24,
|
|
103
112
|
thresholds: { maxLeads: 5, maxSupport: 10, maxFunctional: 10, maxBeatGap: 3, maxProps: 8 },
|
|
104
113
|
});
|
|
105
114
|
|
|
@@ -177,8 +186,14 @@ export const AUDIO_MODEL = VOICE_MODEL;
|
|
|
177
186
|
export const AUDIO_MAX_SECONDS = 120;
|
|
178
187
|
export const AUDIO_MAX_CHARS = 2048;
|
|
179
188
|
export const AUDIO_MAX_REFS = 3;
|
|
180
|
-
/**
|
|
181
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Where the soundtrack is made. `post`: no music in the clips and no per-segment audio node — the
|
|
191
|
+
* score and sound design are made once over the edited episode (`prompt score`). `none`: nothing.
|
|
192
|
+
* `bed` (ambience + sfx + music) / `full` (the bed plus the lines): a Seed Audio node per segment.
|
|
193
|
+
*/
|
|
194
|
+
export const AUDIO_TRACK_MODES = ['post', 'none', 'bed', 'full'];
|
|
195
|
+
/** No per-segment audio node in these modes. */
|
|
196
|
+
export const NO_SEGMENT_TRACK = ['post', 'none'];
|
|
182
197
|
/** What the music does at a cue point. */
|
|
183
198
|
export const MUSIC_CUES = ['enter', 'swell', 'duck', 'drop', 'silence', 'out'];
|
|
184
199
|
/** `fixed` passes `-d` = Σ cut seconds; `auto` leaves it off and lets the model match the prompt. */
|
|
@@ -1385,6 +1400,100 @@ export function audioPrompt(project, episode, seg, opts = {}) {
|
|
|
1385
1400
|
return { segment: seg.id, mode, seconds: total, prompt, references, model: AUDIO_MODEL };
|
|
1386
1401
|
}
|
|
1387
1402
|
|
|
1403
|
+
/** Seconds → SMPTE `HH:MM:SS:FF` at a whole-number frame rate. */
|
|
1404
|
+
export function smpte(seconds, fps = 24) {
|
|
1405
|
+
const f = Math.max(0, Math.round(Number(seconds) * fps));
|
|
1406
|
+
const ff = f % fps, total = Math.floor(f / fps);
|
|
1407
|
+
return [Math.floor(total / 3600), Math.floor(total / 60) % 60, total % 60, ff].map(pad2).join(':');
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* The episode's spotting sheet for the post soundtrack (`audioTrack: post`): the storyboard laid end to
|
|
1412
|
+
* end on one timeline — scene beds (BGE), every line (where the music ducks), each segment's music
|
|
1413
|
+
* intent and cues, the `post: true` sound design, the sfx already in the clips (never doubled), and the
|
|
1414
|
+
* hook / turns / 爽点 / cliff as the 起承转合 anchors. It is the PLANNED timeline: the Sound Designer
|
|
1415
|
+
* conforms it to the edited video's real cuts before writing the Seed Audio prompts, one per section
|
|
1416
|
+
* of ≤ AUDIO_MAX_SECONDS, split at a scene change where one fits.
|
|
1417
|
+
*/
|
|
1418
|
+
export function scoreSheet(project, episode, opts = {}) {
|
|
1419
|
+
const params = paramsOf(project);
|
|
1420
|
+
const fps = Math.round(Number(opts.fps || params.scoreFps)) || 24;
|
|
1421
|
+
const segs = (episode.storyboard?.segments || []).filter((s) => (s.cuts || []).length);
|
|
1422
|
+
const rows = [];
|
|
1423
|
+
const spans = [];
|
|
1424
|
+
const beatAtTime = new Map();
|
|
1425
|
+
let t = 0, bed = null;
|
|
1426
|
+
for (const seg of segs) {
|
|
1427
|
+
const cuts = seg.cuts;
|
|
1428
|
+
const starts = cutStarts(cuts);
|
|
1429
|
+
const total = cuts.reduce((sum, c) => sum + (Number(c.seconds) || 0), 0);
|
|
1430
|
+
const si = Number(seg.sceneIndex);
|
|
1431
|
+
const scene = (episode.scenes || [])[si - 1] || {};
|
|
1432
|
+
const sound = isObject(seg.sound) ? seg.sound : {};
|
|
1433
|
+
const newScene = !spans.length || spans[spans.length - 1].scene !== si;
|
|
1434
|
+
spans.push({ segment: seg.id, scene: si, start: t, end: t + total });
|
|
1435
|
+
rows.push({ at: t, end: t + total, layer: 'segment', segment: seg.id, what: `S${si}${newScene ? ' — new scene' : ''}` });
|
|
1436
|
+
if (newScene || !bed) {
|
|
1437
|
+
bed = { at: t, end: t + total, layer: 'BGE', segment: seg.id, what: sound.ambience || '…' };
|
|
1438
|
+
rows.push(bed);
|
|
1439
|
+
} else bed.end = t + total;
|
|
1440
|
+
cuts.forEach((c, k) => {
|
|
1441
|
+
const [from, to] = c.beats || [0, -1];
|
|
1442
|
+
for (let b = from; b <= to; b++) if (!beatAtTime.has(`${si}:${b}`)) beatAtTime.set(`${si}:${b}`, t + starts[k]);
|
|
1443
|
+
let at = t + starts[k] + (Number(params.speechLeadSeconds) || 0);
|
|
1444
|
+
for (const l of claimedLines(scene, [c])) {
|
|
1445
|
+
const secs = lineSeconds(l.line, params);
|
|
1446
|
+
const who = l.speaker === 'VO' ? 'VO' : characterById(project, l.speaker)?.name || l.speaker;
|
|
1447
|
+
rows.push({ at, end: Math.min(t + total, at + secs), layer: 'DX', segment: seg.id, what: `${who}: ${l.line}` });
|
|
1448
|
+
at += secs;
|
|
1449
|
+
}
|
|
1450
|
+
});
|
|
1451
|
+
for (const x of asList(sound.sfx)) {
|
|
1452
|
+
const k = Number(x?.cut);
|
|
1453
|
+
if (!(k >= 1 && k <= cuts.length)) continue;
|
|
1454
|
+
const at = t + starts[k - 1];
|
|
1455
|
+
rows.push({ at, end: at + (Number(cuts[k - 1].seconds) || 0), layer: x.post === true ? 'design' : 'sfx in clip', segment: seg.id, what: String(x.event) });
|
|
1456
|
+
}
|
|
1457
|
+
const music = musicOf(sound);
|
|
1458
|
+
if (music.intent) {
|
|
1459
|
+
const cues = music.cues.map((c) => ({ at: cueSeconds(c?.at, cuts), do: c?.do, note: c?.note })).filter((c) => c.at != null).sort((a, b) => a.at - b.at);
|
|
1460
|
+
if (!cues.length) rows.push({ at: t, end: t + total, layer: 'BGM', segment: seg.id, what: `bed — ${music.intent}` });
|
|
1461
|
+
cues.forEach((c, i) => rows.push({ at: t + c.at, end: t + (i + 1 < cues.length ? cues[i + 1].at : total), layer: 'BGM', segment: seg.id, what: `${c.do} — ${music.intent}${c.note ? ` (${c.note})` : ''}` }));
|
|
1462
|
+
}
|
|
1463
|
+
t += total;
|
|
1464
|
+
}
|
|
1465
|
+
const point = (ref, what) => {
|
|
1466
|
+
const at = Array.isArray(ref) ? beatAtTime.get(`${Number(ref[0])}:${Number(ref[1])}`) : undefined;
|
|
1467
|
+
if (at != null) rows.push({ at, end: at, layer: 'story', segment: '', what });
|
|
1468
|
+
};
|
|
1469
|
+
point(episode.hookBeat, 'hook (起)');
|
|
1470
|
+
(episode.scenes || []).forEach((sc, si) => (sc.flow || []).forEach((b, bi) => { if (b.turn === true) point([si + 1, bi + 1], 'turn (转)'); }));
|
|
1471
|
+
point(episode.satisfactionBeat, '爽点');
|
|
1472
|
+
if (segs.length) rows.push({ at: t, end: t, layer: 'story', segment: '', what: 'cliff (合) — last frame' });
|
|
1473
|
+
const order = ['story', 'segment', 'BGE', 'BGM', 'design', 'sfx in clip', 'DX'];
|
|
1474
|
+
rows.sort((a, b) => a.at - b.at || order.indexOf(a.layer) - order.indexOf(b.layer));
|
|
1475
|
+
// Seed Audio sections: ≤ AUDIO_MAX_SECONDS, cut at a segment boundary, the latest scene change when one fits.
|
|
1476
|
+
const sections = [];
|
|
1477
|
+
let open = [];
|
|
1478
|
+
for (const sp of spans) {
|
|
1479
|
+
if (open.length && sp.end - open[0].start > AUDIO_MAX_SECONDS) {
|
|
1480
|
+
let cut = open.length;
|
|
1481
|
+
for (let i = open.length - 1; i > 0; i--) if (open[i].scene !== open[i - 1].scene) { cut = i; break; }
|
|
1482
|
+
sections.push(open.slice(0, cut));
|
|
1483
|
+
open = open.slice(cut);
|
|
1484
|
+
}
|
|
1485
|
+
open.push(sp);
|
|
1486
|
+
}
|
|
1487
|
+
if (open.length) sections.push(open);
|
|
1488
|
+
return {
|
|
1489
|
+
episode: `E${pad2(episode.ep)}`,
|
|
1490
|
+
fps,
|
|
1491
|
+
seconds: t,
|
|
1492
|
+
rows: rows.map((r) => ({ ...r, tcIn: smpte(r.at, fps), tcOut: smpte(r.end, fps) })),
|
|
1493
|
+
sections: sections.map((g, i) => ({ n: i + 1, start: g[0].start, end: g[g.length - 1].end, seconds: g[g.length - 1].end - g[0].start, segments: g.map((x) => x.segment), tcIn: smpte(g[0].start, fps), tcOut: smpte(g[g.length - 1].end, fps) })),
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1388
1497
|
// ── gates ───────────────────────────────────────────────────────────────────
|
|
1389
1498
|
//
|
|
1390
1499
|
// A gate is {id, stage, label, run(ctx) -> string[]} where the strings are the
|
|
@@ -3233,7 +3342,7 @@ export const GATES = [
|
|
|
3233
3342
|
gate('audio-track', 'storyboard', 'Seed Audio node per segment: the derived prompt fits the model (≤ 120 s, ≤ 2048 chars, ≤ 3 voices in full mode)', (ctx) => {
|
|
3234
3343
|
const { project, params } = ctx;
|
|
3235
3344
|
const out = [];
|
|
3236
|
-
if (params.audioTrack
|
|
3345
|
+
if (NO_SEGMENT_TRACK.includes(params.audioTrack)) return out;
|
|
3237
3346
|
for (const [, e] of ctx.episodes) {
|
|
3238
3347
|
for (const seg of e.storyboard?.segments || []) {
|
|
3239
3348
|
if (!(seg.cuts || []).length || !isObject(seg.sound)) continue;
|
|
@@ -4086,7 +4195,10 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4086
4195
|
const actionLine = cutLines.length ? [...cutLines, ''] : [];
|
|
4087
4196
|
const accent = String(params.speechAccent || '').trim();
|
|
4088
4197
|
const sound = isObject(seg.sound) ? seg.sound : {};
|
|
4089
|
-
|
|
4198
|
+
// `post: true` sfx (stingers, risers, whooshes — sound design outside the story world) are made over
|
|
4199
|
+
// the edit, never in the clip; in `post` mode the music is too, so the clip is told there is none.
|
|
4200
|
+
const sfxOf = (k) => asList(sound.sfx).filter((x) => x?.post !== true && Number(x?.cut) === k + 1).map((x) => String(x.event)).join('; ');
|
|
4201
|
+
const clipMusic = params.audioTrack === 'post' ? '' : musicOf(sound).intent;
|
|
4090
4202
|
// Who else is on screen while `speaker` has a line: the H3 idiom keeps the lip-sync off them.
|
|
4091
4203
|
// In keyframes mode the @imageN labels are frames, so a person is named by identity instead.
|
|
4092
4204
|
const othersOnScreen = (c, speaker) => (c.characters || []).filter((id) => id !== speaker).map((id) => (params.references === 'sheets' && sheetLabels.get(id) ? `the person in ${sheetLabels.get(id)}` : identityOf(characterById(project, id))));
|
|
@@ -4116,7 +4228,7 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4116
4228
|
: [h3AlignmentLine(cuts), ...voiceLegend];
|
|
4117
4229
|
const look = sheets ? [`Look: ${lookOf()}.`, ''] : [];
|
|
4118
4230
|
const soundscape = [sound.ambience || '…', ...cuts.map((c, k) => (sfxOf(k) ? `at [Shot ${k + 1}] ${sfxOf(k)}` : '')).filter(Boolean)].join('; ');
|
|
4119
|
-
video = [...lead, '', ...look, ...actionLine, 'integrated_multimodal_description:', ...shots, '', `overall_soundscape: ${soundscape}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`, '', `non_diegetic_music: ${
|
|
4231
|
+
video = [...lead, '', ...look, ...actionLine, 'integrated_multimodal_description:', ...shots, '', `overall_soundscape: ${soundscape}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`, '', `non_diegetic_music: ${clipMusic || 'N/A'}`].join('\n');
|
|
4120
4232
|
} else if (mode.family === 'wan') {
|
|
4121
4233
|
const blocks = cuts.map((c, k) => {
|
|
4122
4234
|
const talk = dialogue[k].map((l) => `台词(${l.speakerRef},${l.direction || '…'}):「${l.line}」`);
|
|
@@ -4131,7 +4243,7 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4131
4243
|
...blocks.flatMap((b) => [b, '']),
|
|
4132
4244
|
...(cine && params.references === 'sheets' ? [`画面:${cine.videoPhraseZh}${params.lookPhraseZh ? `,${params.lookPhraseZh}` : ''}。`, ''] : []),
|
|
4133
4245
|
...(grammarLines(seg, params, 'zh').length ? [...grammarLines(seg, params, 'zh'), ''] : []),
|
|
4134
|
-
`音频:对白为${accent || '…'},环境音${sound.ambience || '…'}${sfxAll ? `;${sfxAll}` : ''}${
|
|
4246
|
+
`音频:对白为${accent || '…'},环境音${sound.ambience || '…'}${sfxAll ? `;${sfxAll}` : ''}${clipMusic ? `;配乐:${clipMusic}` : params.audioTrack === 'post' ? ';无配乐' : ''}。${params.subtitles ? '' : `${NO_SUBTITLES.zh}。`}`,
|
|
4135
4247
|
].join('\n');
|
|
4136
4248
|
} else if (params.references === 'sheets') {
|
|
4137
4249
|
const legend = allRefs.map((r) => `${r.label} is ${r.use}.`);
|
|
@@ -4141,7 +4253,7 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4141
4253
|
return `Shot ${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s, ${SIZES[c.size] || c.size}, ${c.camera}): ${c.frame || '…'} ${motionSentence(c)} ${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
|
|
4142
4254
|
});
|
|
4143
4255
|
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `shot ${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
|
|
4144
|
-
video = [...legend, '', ...shots, '', ...actionLine, `Look: ${lookOf()}. Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis across every shot.`, `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${
|
|
4256
|
+
video = [...legend, '', ...shots, '', ...actionLine, `Look: ${lookOf()}. Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis across every shot.`, `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${clipMusic ? ` Music: ${clipMusic}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
|
|
4145
4257
|
} else {
|
|
4146
4258
|
const anchors = [...seedanceAnchors(cuts.length), ...allRefs.filter((r) => r.kind !== 'image').map((r) => `${r.label} is ${r.use}.`)];
|
|
4147
4259
|
const shotOf = (c) => `${SIZES[c.size] || c.size}, ${c.camera}, ${cameraPhrase(c, params)}; ${lightPhrase(c)}${foregroundPhrase(c)}`;
|
|
@@ -4151,7 +4263,7 @@ export function promptSkeleton(project, episode, segmentId) {
|
|
|
4151
4263
|
return `From @image${k + 1} (${starts[k].toFixed(1)}–${(starts[k] + Number(c.seconds)).toFixed(1)} s): ${motionSentence(c)} ${cameraClause(c)} ${talk}`.replace(/\s+/g, ' ').trim();
|
|
4152
4264
|
});
|
|
4153
4265
|
const sfxAll = cuts.map((c, k) => (sfxOf(k) ? `from @image${k + 1}: ${sfxOf(k)}` : '')).filter(Boolean).join('; ');
|
|
4154
|
-
video = [...anchors, ...states, '', ...script, '', ...actionLine, 'Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis.', `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${
|
|
4266
|
+
video = [...anchors, ...states, '', ...script, '', ...actionLine, 'Maintain continuity in character identities, clothing, prop ownership, scene layout and the camera axis.', `Sound: ${sound.ambience || '…'}${sfxAll ? `; ${sfxAll}` : ''}.${accent && dialogue.flat().length ? ` All dialogue in ${accent}.` : ''}${clipMusic ? ` Music: ${clipMusic}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
|
|
4155
4267
|
}
|
|
4156
4268
|
|
|
4157
4269
|
const sceneEntity = sceneById(project, scene.sceneId) || {};
|
|
@@ -4506,7 +4618,9 @@ export function renderSegment(project, episode, segmentId) {
|
|
|
4506
4618
|
lines.push('## 画面提示词(起始瞬间)', '', ...cuts.flatMap((c, k) => [`**${k + 1}.** ${c.frame || ''}`, '']));
|
|
4507
4619
|
if (seg.note) lines.push(`> ${seg.note}`, '');
|
|
4508
4620
|
lines.push('## 视频提示词', '', '```', seg.prompt || '', '```', '');
|
|
4509
|
-
if (params.audioTrack
|
|
4621
|
+
if (params.audioTrack === 'post' && isObject(seg.sound)) {
|
|
4622
|
+
lines.push('## 音轨', '', `配乐与声音设计在剪辑后整集制作(audioTrack post)— 本段的配乐意图与 post 音效写入整集的 spotting sheet:\`prompt score E${pad2(episode.ep)}\`。`, '');
|
|
4623
|
+
} else if (params.audioTrack !== 'none' && isObject(seg.sound)) {
|
|
4510
4624
|
const a = audioPrompt(project, episode, seg);
|
|
4511
4625
|
lines.push(`## 音轨提示词(${AUDIO_MODEL} · ${a.mode} · ${round2(a.seconds)} s)`, '', '```', a.prompt, '```', '');
|
|
4512
4626
|
}
|
|
@@ -4590,7 +4704,7 @@ export function manifest(project, episodes, opts = {}) {
|
|
|
4590
4704
|
if (!sw.nodes?.keyframes?.[k]) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.nodes.keyframes.${k}`, `wji canvas add image -c <${E}> -n "${seg.id} f${k + 1}" --from <display nodes>`);
|
|
4591
4705
|
else if (!sw.assets?.keyframes?.[k]) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.assets.keyframes.${k}`, `wji canvas gen ${sw.nodes.keyframes[k]} → asset`);
|
|
4592
4706
|
}
|
|
4593
|
-
if (paramsOf(project).audioTrack
|
|
4707
|
+
if (!NO_SEGMENT_TRACK.includes(paramsOf(project).audioTrack) && isObject(seg.sound)) {
|
|
4594
4708
|
const full = paramsOf(project).audioTrack === 'full';
|
|
4595
4709
|
if (!sw.nodes?.track) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.nodes.track`, `wji canvas add audio -c <${E}> -n "${seg.id} track" -m ${AUDIO_MODEL}${full ? ' --from <voice display nodes, speaking order>' : ''} --prompt-file <prompt audio ${seg.id}>`);
|
|
4596
4710
|
else if (!sw.assets?.track) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.assets.track`, `wji canvas gen ${sw.nodes.track} → asset → fs set <shot dataset> --add-images <asset>`);
|
|
@@ -4598,6 +4712,11 @@ export function manifest(project, episodes, opts = {}) {
|
|
|
4598
4712
|
if (!sw.nodes?.video) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.nodes.video`, `wji canvas add video -c <${E}> -n "${seg.id}" --from <${paramsOf(project).references === 'sheets' ? 'sheet display nodes' : 'keyframes'}>`);
|
|
4599
4713
|
else if (!sw.assets?.video) add('storyboard', seg.id, `storyboard.segments.${seg.id}.wji.assets.video`, `wji canvas gen ${sw.nodes.video} (video — ask first) → asset`);
|
|
4600
4714
|
}
|
|
4715
|
+
// Post soundtrack: once every clip exists, the episode is edited, then scored and mixed as one.
|
|
4716
|
+
if (paramsOf(project).audioTrack === 'post' && segs.length && segs.every((s) => s.wji?.assets?.video)) {
|
|
4717
|
+
if (!ew.assets?.edit) add('storyboard', E, 'wji.assets.edit', `edit the clips into the locked ${E} cut (no music) → wji asset upload <${E} edit.mp4>`);
|
|
4718
|
+
else if (!ew.assets?.mix) add('storyboard', E, 'wji.assets.mix', `prompt score ${E} → conform to the edit → Seed Audio per section (wji canvas add audio -c <${E}> -n "${E} score N" -m ${AUDIO_MODEL} --prompt-file …; ask first) → mix under the edit → wji asset upload <${E} mix.mp4>`);
|
|
4719
|
+
}
|
|
4601
4720
|
}
|
|
4602
4721
|
return items;
|
|
4603
4722
|
}
|
|
@@ -4856,6 +4975,27 @@ function main(argv) {
|
|
|
4856
4975
|
return 0;
|
|
4857
4976
|
}
|
|
4858
4977
|
|
|
4978
|
+
if (cmd === 'dashboard') {
|
|
4979
|
+
const loaded = loadProject(input);
|
|
4980
|
+
const output = resolve(flag(rest, '--output', join(loaded.paths.dir, 'dashboard.html')));
|
|
4981
|
+
if (existsSync(output) && !flag(rest, '--force')) {
|
|
4982
|
+
throw new Error(`${output} exists (pass --force to overwrite)`);
|
|
4983
|
+
}
|
|
4984
|
+
const gateResults = runGates(makeContext(loaded.project, loaded.episodes));
|
|
4985
|
+
const html = renderDashboard({
|
|
4986
|
+
project: loaded.project,
|
|
4987
|
+
episodes: loaded.episodes,
|
|
4988
|
+
gateResults,
|
|
4989
|
+
projectDir: loaded.paths.dir,
|
|
4990
|
+
assetsDir: flag(rest, '--assets'),
|
|
4991
|
+
outputPath: output,
|
|
4992
|
+
});
|
|
4993
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
4994
|
+
writeFileSync(output, html, 'utf8');
|
|
4995
|
+
console.log(`✓ ${output}`);
|
|
4996
|
+
return 0;
|
|
4997
|
+
}
|
|
4998
|
+
|
|
4859
4999
|
if (cmd === 'render') {
|
|
4860
5000
|
const what = pos[1];
|
|
4861
5001
|
const { project, episodes } = loadProject(input);
|
|
@@ -4898,6 +5038,28 @@ function main(argv) {
|
|
|
4898
5038
|
return 0;
|
|
4899
5039
|
}
|
|
4900
5040
|
|
|
5041
|
+
if (cmd === 'prompt' && pos[1] === 'score') {
|
|
5042
|
+
const ep = Number((pos[2] || '').match(/^E(\d+)$/)?.[1]);
|
|
5043
|
+
const { project, episodes } = loadProject(input);
|
|
5044
|
+
if (!ep || !episodes[ep]) throw new Error('usage: prompt <project> score E01 [--fps 24] [--json]');
|
|
5045
|
+
const out = scoreSheet(project, episodes[ep], { fps: flag(rest, '--fps') });
|
|
5046
|
+
if (flag(rest, '--json')) console.log(JSON.stringify(out, null, 2));
|
|
5047
|
+
else {
|
|
5048
|
+
console.log(`# ${out.episode} · post soundtrack spotting sheet · ${round2(out.seconds)} s · ${smpte(out.seconds, out.fps)} @ ${out.fps} fps`);
|
|
5049
|
+
console.log('');
|
|
5050
|
+
console.log('PLANNED timeline (storyboard end to end). Conform every row to the edited video\'s real cuts before writing prompts.');
|
|
5051
|
+
console.log('Layers: BGE / BGM / design are made in post; "sfx in clip" and DX are already in the video — never double them; BGM ducks under DX.');
|
|
5052
|
+
console.log('');
|
|
5053
|
+
console.log('| TC in | TC out | sec | layer | segment | what |');
|
|
5054
|
+
console.log('| --- | --- | --- | --- | --- | --- |');
|
|
5055
|
+
for (const r of out.rows) console.log(`| ${r.tcIn} | ${r.tcOut} | ${r.at.toFixed(1)}–${r.end.toFixed(1)} | ${r.layer} | ${r.segment} | ${String(r.what).replace(/\|/g, '/')} |`);
|
|
5056
|
+
console.log('');
|
|
5057
|
+
console.log(`Seed Audio sections (≤ ${AUDIO_MAX_SECONDS} s each, local time 0 = section start):`);
|
|
5058
|
+
for (const x of out.sections) console.log(` ${x.n}. ${x.tcIn} → ${x.tcOut} (${round2(x.seconds)} s) · ${x.segments.join(', ')}`);
|
|
5059
|
+
}
|
|
5060
|
+
return 0;
|
|
5061
|
+
}
|
|
5062
|
+
|
|
4901
5063
|
if (cmd === 'prompt' && pos[1] === 'voice') {
|
|
4902
5064
|
const { project } = loadProject(input);
|
|
4903
5065
|
const c = characterById(project, pos[2]);
|