@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.
@@ -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
- // A Seed Audio node per segment: the soundtrack bed by default (ambience, sfx, timed music).
102
- audioTrack: 'bed',
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
- /** What the per-segment audio node generates: nothing, a bed (ambience + sfx + music) or the full track with the lines. */
181
- export const AUDIO_TRACK_MODES = ['none', 'bed', 'full'];
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 === 'none') return out;
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
- const sfxOf = (k) => asList(sound.sfx).filter((x) => Number(x?.cut) === k + 1).map((x) => String(x.event)).join('; ');
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: ${musicOf(sound).intent || 'N/A'}`].join('\n');
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}` : ''}${musicOf(sound).intent ? `;配乐:${musicOf(sound).intent}` : ''}。${params.subtitles ? '' : `${NO_SUBTITLES.zh}。`}`,
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}.` : ''}${musicOf(sound).intent ? ` Music: ${musicOf(sound).intent}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
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}.` : ''}${musicOf(sound).intent ? ` Music: ${musicOf(sound).intent}.` : ' No music.'}${params.subtitles ? '' : ` ${NO_SUBTITLES.en[0].toUpperCase()}${NO_SUBTITLES.en.slice(1)}.`}`].join('\n');
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 !== 'none' && isObject(seg.sound)) {
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 !== 'none' && isObject(seg.sound)) {
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]);