@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.
@@ -404,13 +404,36 @@ world, same faces, right size, nobody extra), fix, then continue. An episode is
404
404
  a wrong direction costs the whole batch — which is why this is opt-in and the default references
405
405
  the sheets directly.
406
406
 
407
- ## Soundtrack node — Seed Audio is not a TTS
407
+ ## Soundtrack — scored over the edit, not per clip
408
+
409
+ Each segment is a separate generation, so music and sound design made inside a clip stop at its
410
+ last frame: a cue cannot swell across a cut into the next segment, and every clip invents its own
411
+ score. So by default (`params.audioTrack: post`) **the clips carry no music**: the video prompt
412
+ says `No music.` (H3 `non_diegetic_music: N/A`, Wan `无配乐`), keeps the dialogue, the ambience and
413
+ the in-picture sfx, and drops every sfx marked `post: true` (stingers, risers, whooshes, impact
414
+ hits — sound design outside the story world). The storyboard still writes `sound.music` (intent and
415
+ cues) and the `post: true` hits: they are the **spotting notes**. When every clip is in, the episode
416
+ is edited, and the Sound Designer scores the edit as one piece on its timecode:
417
+
418
+ ```bash
419
+ $SD prompt <project> score E01 # planned spotting sheet: SMPTE rows + ≤120 s Seed Audio sections
420
+ ```
421
+
422
+ The sheet lays the segments end to end — scene beds (BGE), every line (where the music ducks), each
423
+ segment's music cues at their episode time, the `post` sound design, the sfx already in the clips
424
+ (never doubled) and the hook / turns / 爽点 / cliff as the 起承转合 anchors. It is the *planned*
425
+ timeline: the Sound Designer conforms it to the edit's real cuts
426
+ (`mix-over-video.md`) before any prompt is written. `manifest`
427
+ then asks for the episode's `wji.assets.edit` and `wji.assets.mix`.
428
+
429
+ ### Per-segment soundtrack node (`audioTrack: bed` / `full`)
408
430
 
409
431
  `seed-audio-1.0` (the voice-sample model of stage 2) generates a whole **audio scene** from one
410
432
  prompt: speech, sound effects, ambience and music in one track, up to 120 s, and a
411
433
  **`[start s:end s]` timecode in front of an element pins it to that interval** (100 ms
412
- precision). So every segment gets a soundtrack node next to its video node, and the prompt is
413
- derived — not written — from `segment.sound` and the cut timing:
434
+ precision). In `bed` / `full` every segment gets a soundtrack node next to its video node and the
435
+ music goes into the clip prompt too; the prompt is derived — not written — from `segment.sound` and
436
+ the cut timing:
414
437
 
415
438
  ```bash
416
439
  node {baseDir}/scripts/short-drama.mjs prompt <project> audio E01-05 # bed (default)
@@ -427,13 +450,13 @@ Music, instrumental: the low cello note.
427
450
  No dialogue, no vocals, no narration.
428
451
  ```
429
452
 
430
- `params.audioTrack` decides what the node makes: **`bed`** (default) — ambience + sfx + timed
453
+ `params.audioTrack` decides what the node makes: **`bed`** — ambience + sfx + timed
431
454
  music, no voices: the editor's music-and-effects track, and what the video model's same-pass sound
432
455
  is checked against; **`full`** — the bed plus every line at its cut (start = cut start +
433
456
  `speechLeadSeconds`, length from the reading rate), each read in the connected voice sample
434
457
  (`--from` the voice display nodes in speaking order → `@Audio1..3`; Seed Audio takes at most 3, so a
435
- segment with four speakers stays `bed`); **`none`** — no node. Music dynamics are written as cues on
436
- `sound.music`:
458
+ segment with four speakers stays `bed`); **`none`** / **`post`** — no node. Music dynamics are
459
+ written as cues on `sound.music` in every mode:
437
460
 
438
461
  ```json
439
462
  "music": { "intent": "the low cello note",
@@ -109,11 +109,11 @@ By default the video node references the sheets directly — **no storyboard ima
109
109
  video node, and the video node then references those instead of the sheets.
110
110
 
111
111
  - Display nodes: `canvas add image --asset <assetId> -n "C01 sheet"` and `canvas add audio --asset <voiceAsset> -n "C01 voice"` — record both in the episode's `wji.displayNodes` (`"C01:sheet"`, `"C01:voice"`) so every generate node can `--from` them. Pictures become `@imageN`, voices `@audioN`, numbered per type in connection order.
112
- - Per segment, beside the video node, a **soundtrack audio node** (`canvas add audio -n "E01-01 track" -m seed-audio-1.0 --prompt-file …`, prompt from `prompt audio E01-01`) → `storyboard.segments.E01-01.wji.nodes.track`; its asset joins the shot dataset's `images[]`.
112
+ - Soundtrack: in `audioTrack: post` (default) no per-segment node — after the edit, the episode canvas gets one **score node per ≤ 120 s section** (`canvas add audio -n "E01 score 1" -m seed-audio-1.0 --prompt-file …`, from the conformed `prompt score E01` sheet), and the episode binds `wji.assets.edit` / `wji.assets.mix`. In `bed` / `full`, per segment, beside the video node, a **soundtrack audio node** (`canvas add audio -n "E01-01 track" -m seed-audio-1.0 --prompt-file …`, prompt from `prompt audio E01-01`) → `storyboard.segments.E01-01.wji.nodes.track`; its asset joins the shot dataset's `images[]`.
113
113
  - Video: `canvas add video -n "E01-01" --from <sheet display nodes in legend order> <voice display nodes of everyone who speaks> -m <the mode's model> -r <params.ratio> [-d <Σ cut seconds>] --prompt-file prompt.txt`. The model and its resolution spelling come from `prompt segment` (`video.model`, `video.resolution`). `-d` **only in `videoDuration: fixed`** — it switches auto-duration off, which pins the clip to the storyboard's timestamps but also fixes the price; in `auto` (the default) leave it out and the model matches the clip to the prompt.
114
114
  - Prompt text node: `canvas add text -n "E01-01 prompt" --text-file prompt.txt` beside the video node, for people reading the canvas.
115
115
  - Keyframes (opt-in only): `canvas add image -n "E01-01 f<k>" --from <sheet display nodes> -m <image model> -r <params.ratio> -p "<plan prefix + frame>"`, and the video node then `--from f1…fN` instead of the sheets.
116
- - Shot dataset: `dataset new <shot/E01 folder> "E01-01" -t shot -d "<one line>"`, `--set video_prompt=@prompt.txt`, images = the soundtrack (and keyframes, if any), `primaryVideo` = the clip.
116
+ - Shot dataset: `dataset new <shot/E01 folder> "E01-01" -t shot -d "<one line>"`, `--set video_prompt=@prompt.txt`, images = the soundtrack in `bed` / `full` (and keyframes, if any), `primaryVideo` = the clip.
117
117
 
118
118
  Placement: `canvas add` puts a node right of its `--from` nodes; pass `--at x,y`
119
119
  for the row starts (title nodes) so blocks do not overlap. Take ids from the
@@ -3,10 +3,11 @@
3
3
  // case — a gate that cannot fail is not a gate.
4
4
 
5
5
  import assert from 'node:assert/strict';
6
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
6
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { dirname, join } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { renderDashboard } from './short-drama-dashboard.mjs';
10
11
  import {
11
12
  CAMERAS, GATES, STAGES, STYLE_PRESETS, CINEMATOGRAPHY_PRESETS, cinematographyForGenre, ACTION_STYLES, ACTION_RULES, actionForGenre, IP_NAMES_RE, GRAMMARS, GRAMMAR_RULES, COMEDY_STYLES, FACESLAP, grammarsOf, grammarLines, grammarForGenre, comedyForGenre, COMPOSITION_STYLES, COMPOSITION_DEVICES, compositionOf, compositionForGenre, STORY_GENRES, STORY_RULES, storyOf, storyForGenre, FORMATS, span, runsAtLeast, formatForGenre, cinematographyOf, stopNumber,
12
13
  bindPath, claimedLines, containsName, cutStarts, dialogueSeconds, episodeSeconds,
@@ -17,7 +18,7 @@ import {
17
18
  speechDirection, cameraPhrase, lightPhrase, foregroundPhrase, focalOf, LIGHT_DIRECTIONS, identityOf, isEnglish,
18
19
  renderGuideline, renderDialogue, scriptSelfCheck, beatOffsets, turnPoints, parseAge, talkAndAction, normalizeRegister, REGISTERS, REGISTER_ACCENT,
19
20
  cutStart, segmentCharacters, keyScenes, shotTexts, motionSentence, cameraClause, PURPOSE_KINDS, ANGLES, HEIGHTS, NO_SUBTITLES, LIPS_CLOSED,
20
- audioPrompt, musicOf, cueSeconds, AUDIO_MODEL, AUDIO_MAX_CHARS,
21
+ audioPrompt, musicOf, cueSeconds, AUDIO_MODEL, AUDIO_MAX_CHARS, AUDIO_MAX_SECONDS, scoreSheet, smpte,
21
22
  VIDEO_MODELS, VIDEO_MODES, DEFAULT_PARAMS, videoModel, videoResolutionFor, nearestResolution, segmentPlan, videoReferences,
22
23
  } from './short-drama.mjs';
23
24
 
@@ -45,6 +46,38 @@ const breaks = (gateId, project, episode, needle) => {
45
46
  if (needle) ok(f.some((m) => m.includes(needle)), `${gateId} names the problem (${needle}): ${f.join(' | ')}`);
46
47
  };
47
48
 
49
+ // ── dashboard ──────────────────────────────────────────────────────────────
50
+ {
51
+ const outputPath = join(tmpdir(), 'wji-short-drama-dashboard-test.html');
52
+ const html = renderDashboard({
53
+ project: PROJECT,
54
+ episodes: { 1: E01 },
55
+ gateResults: runGates(makeContext(PROJECT, { 1: E01 })),
56
+ projectDir: FIXTURE,
57
+ outputPath,
58
+ });
59
+ ok(html.startsWith('<!doctype html>'), 'dashboard is a standalone HTML document');
60
+ ok(html.includes('夜班便利店') && html.includes('人物设定'), 'dashboard renders project sections');
61
+ ok(html.includes('E01-01') && html.includes('制作总览'), 'dashboard renders episode storyboard');
62
+ ok(html.includes('图片占位'), 'dashboard keeps an image placeholder when no media is bound');
63
+
64
+ const mediaDir = mkdtempSync(join(tmpdir(), 'wji-dashboard-media-'));
65
+ const localImage = join(mediaDir, 'lin.jpg');
66
+ writeFileSync(localImage, '');
67
+ const withMedia = clone(PROJECT);
68
+ withMedia.characters[0].image.url = localImage;
69
+ withMedia.characters[1].video = { url: 'https://cdn.example.com/chen.mp4', type: 'video' };
70
+ const mediaHtml = renderDashboard({
71
+ project: withMedia,
72
+ episodes: { 1: E01 },
73
+ projectDir: mediaDir,
74
+ outputPath: join(mediaDir, 'dashboard.html'),
75
+ });
76
+ ok(mediaHtml.includes('lin.jpg') && mediaHtml.includes('<img'), 'dashboard uses a local image path');
77
+ ok(mediaHtml.includes('<video src="https://cdn.example.com/chen.mp4"'), 'dashboard uses a WJI video URL');
78
+ rmSync(mediaDir, { recursive: true, force: true });
79
+ }
80
+
48
81
  // ── fixture is green ────────────────────────────────────────────────────────
49
82
  {
50
83
  const results = runGates(makeContext(PROJECT, { 1: E01 }));
@@ -1394,21 +1427,24 @@ const breaks = (gateId, project, episode, needle) => {
1394
1427
  eq(cueSeconds(3, seg5.cuts), 6, 'a cue at a cut number is that cut\'s start');
1395
1428
  eq(cueSeconds('10.5s', seg5.cuts), 10.5, 'a cue in seconds is read as seconds');
1396
1429
  eq(cueSeconds('later', seg5.cuts), null, 'a cue that is neither is null');
1397
- const bed = audioPrompt(PROJECT, E01, seg5);
1398
- eq(bed.mode, 'bed', 'the default track is a bed');
1430
+ // A per-segment soundtrack node is the `bed` / `full` mode; the default is `post` (scored over the edit).
1431
+ const PB = clone(PROJECT); PB.params.audioTrack = 'bed';
1432
+ eq(paramsOf(PROJECT).audioTrack, 'post', 'the default soundtrack is made in post, over the edit');
1433
+ const bed = audioPrompt(PB, E01, seg5);
1434
+ eq(bed.mode, 'bed', 'bed mode makes a bed');
1399
1435
  eq(bed.model, AUDIO_MODEL, 'the soundtrack node is Seed Audio');
1400
1436
  ok(bed.prompt.startsWith('[0.0s:11.0s] Ambience, continuous: the low hum'), 'the bed opens with the timecoded ambience');
1401
1437
  ok(bed.prompt.includes('[6.0s:9.0s] SFX: a single ring of the brass wind chimes'), 'sfx are timecoded to their cut');
1402
1438
  ok(bed.prompt.includes('[6.0s:10.5s] the music swells — on the chime.') && bed.prompt.includes('[10.5s:11.0s] the music falls silent'), 'music cues become timecoded states');
1403
1439
  ok(bed.prompt.includes('No dialogue, no vocals, no narration.'), 'a bed carries no voices');
1404
1440
  eq(bed.references.length, 0, 'a bed references no voices');
1405
- const full = audioPrompt(PROJECT, E01, E01.storyboard.segments[1], { mode: 'full' });
1441
+ const full = audioPrompt(PB, E01, E01.storyboard.segments[1], { mode: 'full' });
1406
1442
  eq(full.references.map((r) => r.label).join(','), '@Audio1,@Audio2', 'full mode references the speakers as @AudioN');
1407
1443
  ok(full.prompt.includes('[5.5s:7.1s] @Audio1 (the stocky man in his sixties with a grey crew cut), slow and gravelly'), 'a line is timecoded into its cut after the lead-in, in its voice and direction');
1408
1444
  ok(full.prompt.includes('says: 又是你值夜班。'), 'the line is verbatim');
1409
- const noMusic = audioPrompt(PROJECT, E01, E01.storyboard.segments[0]);
1445
+ const noMusic = audioPrompt(PB, E01, E01.storyboard.segments[0]);
1410
1446
  ok(noMusic.prompt.includes('No music.'), 'N/A music is stated');
1411
- const md = renderSegment(PROJECT, E01, 'E01-05');
1447
+ const md = renderSegment(PB, E01, 'E01-05');
1412
1448
  ok(md.includes('## 音轨提示词(seed-audio-1.0 · bed · 11 s)') && md.includes('配乐:the low cello note(镜1 enter,镜3 swell,10.5s silence)'), 'segment markdown carries the soundtrack prompt and the cues');
1413
1449
  // gates
1414
1450
  const e = clone(E01); e.storyboard.segments[4].sound.music = { intent: '', cues: [] };
@@ -1418,7 +1454,7 @@ const breaks = (gateId, project, episode, needle) => {
1418
1454
  const e3 = clone(E01); e3.storyboard.segments[4].sound.music.cues[0].at = '40s';
1419
1455
  breaks('sound', PROJECT, e3, 'inside the segment');
1420
1456
  const e4 = clone(E01); e4.storyboard.segments[4].sound.ambience = 'x'.repeat(AUDIO_MAX_CHARS);
1421
- breaks('audio-track', PROJECT, e4, 'max 2048');
1457
+ breaks('audio-track', PB, e4, 'max 2048');
1422
1458
  const p = clone(PROJECT); p.params.audioTrack = 'full';
1423
1459
  p.characters.push({ id: 'C04', name: '路人', tier: 'functional', role: 'r', from: ['原创'], identityEn: 'a passer-by in a wet coat', voice: { videoDirection: 'flat' } });
1424
1460
  const e5 = clone(E01); e5.scenes[1].characters.push('C03', 'C04');
@@ -1428,9 +1464,51 @@ const breaks = (gateId, project, episode, needle) => {
1428
1464
  eq(failures('audio-track', p2, E01).length, 0, 'audioTrack none skips the gate');
1429
1465
  const p3 = clone(PROJECT); p3.params.audioTrack = 'stems';
1430
1466
  breaks('params', p3, null, 'audioTrack');
1431
- const items = manifest(PROJECT, { 1: E01 });
1432
- ok(items.some((i) => i.item === 'storyboard.segments.E01-01.wji.nodes.track'), 'manifest asks for the soundtrack node');
1467
+ const items = manifest(PB, { 1: E01 });
1468
+ ok(items.some((i) => i.item === 'storyboard.segments.E01-01.wji.nodes.track'), 'bed mode: manifest asks for the soundtrack node');
1433
1469
  ok(!manifest(p2, { 1: E01 }).some((i) => i.item.includes('.nodes.track')), 'audioTrack none asks for no soundtrack node');
1470
+ // post (default): no music in any clip, no per-segment track, the episode is scored over the edit
1471
+ ok(!manifest(PROJECT, { 1: E01 }).some((i) => i.item.includes('.nodes.track')), 'post asks for no per-segment soundtrack node');
1472
+ eq(failures('audio-track', PROJECT, E01).length, 0, 'post skips the per-segment audio gate');
1473
+ for (const video of ['h3', 'seedance2', 'wan']) {
1474
+ for (const references of ['keyframes', 'sheets']) {
1475
+ const pv = clone(PROJECT); pv.params.video = video; pv.params.references = references;
1476
+ const vp = promptSkeleton(pv, E01, 'E01-05').video.prompt;
1477
+ ok(!vp.includes('cello'), `post: the music intent stays out of the clip (${video}/${references})`);
1478
+ ok(video === 'h3' ? vp.includes('non_diegetic_music: N/A') : video === 'wan' ? vp.includes('无配乐') : vp.includes('No music.'), `post: the clip is told there is no music (${video}/${references})`);
1479
+ const pb = clone(pv); pb.params.audioTrack = 'bed';
1480
+ ok(promptSkeleton(pb, E01, 'E01-05').video.prompt.includes('cello'), `bed: the music intent is still in the clip (${video}/${references})`);
1481
+ }
1482
+ }
1483
+ const ePost = clone(E01); ePost.storyboard.segments[4].sound.sfx.push({ cut: 3, event: 'SPECIAL: a reversed-cymbal riser into a hard stop', post: true });
1484
+ ok(!promptSkeleton(PROJECT, ePost, 'E01-05').video.prompt.includes('reversed-cymbal'), 'a post: true sfx stays out of the clip');
1485
+ ok(audioPrompt(PB, ePost, ePost.storyboard.segments[4]).prompt.includes('reversed-cymbal'), 'a post: true sfx is still in a per-segment bed');
1486
+ ok(renderSegment(PROJECT, E01, 'E01-05').includes('prompt score E01'), 'post: segment markdown points at the episode score sheet');
1487
+ const sheet = scoreSheet(PROJECT, ePost);
1488
+ eq(sheet.seconds, 68, 'the score sheet lays the segments end to end');
1489
+ eq(sheet.fps, 24, 'the score sheet defaults to 24 fps');
1490
+ eq(smpte(63.5, 24), '00:01:03:12', 'SMPTE is HH:MM:SS:FF');
1491
+ eq(smpte(3725, 25), '01:02:05:00', 'SMPTE carries hours');
1492
+ ok(sheet.rows.some((r) => r.layer === 'BGM' && r.at === 63 && r.what.startsWith('swell')), 'a music cue lands at its segment offset + cut start');
1493
+ ok(sheet.rows.some((r) => r.layer === 'design' && r.at === 63 && r.what.includes('reversed-cymbal')), 'post sfx are sound design on the sheet');
1494
+ ok(sheet.rows.some((r) => r.layer === 'sfx in clip' && r.what.includes('wind chimes')), 'in-clip sfx are listed so they are not doubled');
1495
+ ok(sheet.rows.some((r) => r.layer === 'DX' && r.at === 3.5), 'lines are spotted after the lead-in, for ducking');
1496
+ eq(sheet.rows.filter((r) => r.layer === 'BGE').length, 2, 'one ambience bed per scene, chained across its segments');
1497
+ eq(sheet.rows.find((r) => r.layer === 'BGE' && r.segment === 'E01-02').end, 68, 'the scene bed runs to the scene end');
1498
+ ok(sheet.rows.some((r) => r.layer === 'story' && r.what.startsWith('hook')) && sheet.rows.some((r) => r.layer === 'story' && r.what.startsWith('cliff')), 'the hook and the cliff anchor 起 / 合');
1499
+ eq(sheet.sections.length, 1, 'a 68 s episode is one Seed Audio section');
1500
+ const eLong = clone(E01);
1501
+ for (const sg of eLong.storyboard.segments) for (const c of sg.cuts) c.seconds *= 2; // 136 s
1502
+ const long = scoreSheet(PROJECT, eLong, { fps: 25 });
1503
+ ok(long.sections.length === 2 && long.sections.every((x) => x.seconds <= AUDIO_MAX_SECONDS), 'a long episode splits into ≤ 120 s sections');
1504
+ eq(long.sections[0].segments.join(','), 'E01-01', 'the split falls on the scene change');
1505
+ eq(long.fps, 25, '--fps overrides the frame rate');
1506
+ const pp = clone(PROJECT); const eDone = clone(E01);
1507
+ for (const sg of eDone.storyboard.segments) sg.wji = { ...(sg.wji || {}), assets: { ...(sg.wji?.assets || {}), video: 'a1' } };
1508
+ ok(manifest(pp, { 1: eDone }).some((i) => i.item === 'wji.assets.edit'), 'post: once every clip exists the manifest asks for the edit');
1509
+ eDone.wji = { ...(eDone.wji || {}), assets: { edit: 'e1' } };
1510
+ ok(manifest(pp, { 1: eDone }).some((i) => i.item === 'wji.assets.mix'), 'post: after the edit it asks for the scored mix');
1511
+ ok(!manifest(pp, { 1: E01 }).some((i) => i.item.startsWith('wji.assets.')), 'post: no edit item while clips are missing');
1434
1512
  ok(voiceSamplePrompt(PROJECT.characters[0], paramsOf(PROJECT)).includes('干声,无配乐无音效'), 'the voice sample is asked for dry');
1435
1513
  }
1436
1514