@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
|
@@ -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
|
|
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).
|
|
413
|
-
derived — not written — from `segment.sound` and
|
|
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`**
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
1398
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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',
|
|
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(
|
|
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
|
|