@wji.ai/cli 0.8.1 → 0.8.2
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/package.json +1 -1
- package/skill/short-drama/SKILL.md +35 -21
- package/skill/short-drama/dashboard.html +863 -0
- package/skill/short-drama/examples/night-shift/episodes/E01.json +1 -1
- package/skill/short-drama/reference/canvas-recipes.md +1 -1
- package/skill/short-drama/reference/edit-tools.md +65 -0
- package/skill/short-drama/reference/mix-over-video.md +5 -1
- package/skill/short-drama/reference/operations-cli.md +3 -3
- package/skill/short-drama/reference/schema.md +14 -10
- package/skill/short-drama/reference/stage-storyboard.md +3 -2
- package/skill/short-drama/scripts/selftest.mjs +327 -29
- package/skill/short-drama/scripts/short-drama.mjs +410 -82
- package/skill/short-drama/scripts/short-drama-dashboard.mjs +0 -459
|
@@ -7,7 +7,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';
|
|
11
10
|
import {
|
|
12
11
|
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,
|
|
13
12
|
bindPath, claimedLines, containsName, cutStarts, dialogueSeconds, episodeSeconds,
|
|
@@ -20,7 +19,10 @@ import {
|
|
|
20
19
|
cutStart, segmentCharacters, keyScenes, shotTexts, motionSentence, cameraClause, PURPOSE_KINDS, ANGLES, HEIGHTS, NO_SUBTITLES, LIPS_CLOSED,
|
|
21
20
|
audioPrompt, musicOf, cueSeconds, AUDIO_MODEL, AUDIO_MAX_CHARS, AUDIO_MAX_SECONDS, scoreSheet, smpte,
|
|
22
21
|
VIDEO_MODELS, VIDEO_MODES, DEFAULT_PARAMS, videoModel, videoResolutionFor, nearestResolution, segmentPlan, videoReferences,
|
|
22
|
+
formatGates, displayName, imagePrompt, imagePromptKit, foldZh, IMAGE_PROMPT_LANGS,
|
|
23
23
|
} from './short-drama.mjs';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { copyFileSync, existsSync, symlinkSync } from 'node:fs';
|
|
24
26
|
|
|
25
27
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
28
|
const FIXTURE = join(here, '../examples/night-shift');
|
|
@@ -48,34 +50,35 @@ const breaks = (gateId, project, episode, needle) => {
|
|
|
48
50
|
|
|
49
51
|
// ── dashboard ──────────────────────────────────────────────────────────────
|
|
50
52
|
{
|
|
51
|
-
const
|
|
52
|
-
const html = renderDashboard({
|
|
53
|
-
project: PROJECT,
|
|
54
|
-
episodes: { 1: E01 },
|
|
55
|
-
gateResults: runGates(makeContext(PROJECT, { 1: E01 })),
|
|
56
|
-
projectDir: FIXTURE,
|
|
57
|
-
outputPath,
|
|
58
|
-
});
|
|
53
|
+
const html = readFileSync(join(here, '../dashboard.html'), 'utf8');
|
|
59
54
|
ok(html.startsWith('<!doctype html>'), 'dashboard is a standalone HTML document');
|
|
60
|
-
ok(html.includes('
|
|
61
|
-
ok(html.includes('
|
|
62
|
-
ok(html.includes('
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
55
|
+
ok(html.includes('project.json') && html.includes('episodes/E'), 'dashboard loads project.json and episode files');
|
|
56
|
+
ok(html.includes('/assets/check-status') && html.includes('/assets/list/'), 'dashboard resolves an asset id to its file url');
|
|
57
|
+
ok(html.includes('圖片佔位'), 'dashboard keeps an image placeholder when no media is bound');
|
|
58
|
+
ok(!html.includes('夜班便利店'), 'dashboard does not bake the fixture into the page');
|
|
59
|
+
ok(!html.includes('asset download'), 'dashboard does not download asset files');
|
|
60
|
+
ok(html.includes('data-target') && html.includes('scrollTo'), 'dashboard left nav scrolls the content column');
|
|
61
|
+
ok(html.includes('episode.scenes') && html.includes('△') && html.includes('<details class="script">'), 'dashboard folds the episode script from scenes[].flow');
|
|
62
|
+
ok(html.includes('localStorage.setItem') && html.includes('wji-short-drama-token') && html.includes('login-dialog'), 'dashboard saves the API key in localStorage');
|
|
63
|
+
ok(html.includes('https://app.wji.ai') && html.includes('InfiniteCanvas') && html.includes("app: 'Dataset'"), 'dashboard links to the WJI canvas and dataset pages');
|
|
64
|
+
ok(html.includes('id="viewer"') && html.includes('data-kind'), 'dashboard enlarges an image or video on click');
|
|
65
|
+
|
|
66
|
+
const dir = mkdtempSync(join(tmpdir(), 'sd-dash-copy-'));
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(join(dir, 'project.json'), JSON.stringify(PROJECT));
|
|
69
|
+
const engine = join(here, 'short-drama.mjs');
|
|
70
|
+
const first = spawnSync(process.execPath, [engine, 'dashboard', dir], { encoding: 'utf8' });
|
|
71
|
+
eq(first.status, 0, `dashboard copies a missing file: ${first.stderr}`);
|
|
72
|
+
ok(existsSync(join(dir, 'dashboard.html')), 'dashboard.html is copied beside project.json');
|
|
73
|
+
const stamped = join(dir, 'dashboard.html');
|
|
74
|
+
writeFileSync(stamped, 'keep');
|
|
75
|
+
const second = spawnSync(process.execPath, [engine, 'dashboard', dir], { encoding: 'utf8' });
|
|
76
|
+
eq(second.status, 0, 'dashboard leaves an existing file');
|
|
77
|
+
eq(readFileSync(stamped, 'utf8'), 'keep', 'an existing dashboard.html is not overwritten');
|
|
78
|
+
ok(second.stdout.includes('already beside project.json'), 'dashboard reports the file is already there');
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
// ── fixture is green ────────────────────────────────────────────────────────
|
|
@@ -1517,7 +1520,11 @@ const breaks = (gateId, project, episode, needle) => {
|
|
|
1517
1520
|
const s = seedScript(PROJECT, 2);
|
|
1518
1521
|
eq(s.targetSeconds, 60, 'seed target seconds from params');
|
|
1519
1522
|
eq(s.beatsClaimed.join(','), 'B02', 'seed claims the episode beats');
|
|
1520
|
-
eq(s.seedNote.previousCliff, PROJECT.outline.episodes[0].
|
|
1523
|
+
eq(s.seedNote.previousCliff, PROJECT.outline.episodes[0].hook, 'seed carries the previous cliff (the outline hook of the episode before)');
|
|
1524
|
+
eq(s.hook, '', 'seed leaves the opening hook for the writer');
|
|
1525
|
+
eq(s.cliff, PROJECT.outline.episodes[1].hook, 'seed cliff is the outline hook (the question that makes them tap next)');
|
|
1526
|
+
eq(s.seedNote.suspense, PROJECT.outline.episodes[1].suspense, 'seed note carries the outline suspense');
|
|
1527
|
+
ok(s.seedNote.openingHook.includes('opens the episode'), 'seed note says the hook is the opening image');
|
|
1521
1528
|
eq(s.scenes.length, 0, 'seed leaves scenes empty');
|
|
1522
1529
|
assert.throws(() => seedScript(PROJECT, 9), /no episode 9/, 'seed refuses unknown episode');
|
|
1523
1530
|
const sb = seedStoryboard(PROJECT, E01);
|
|
@@ -2298,4 +2305,295 @@ const breaks = (gateId, project, episode, needle) => {
|
|
|
2298
2305
|
eq(slug('E01-01 便利店 / 夜班'), 'e01-01-便利店-夜班', 'slug keeps CJK, drops separators');
|
|
2299
2306
|
}
|
|
2300
2307
|
|
|
2308
|
+
// ── gate inventory (crew engine) ────────────────────────────────────────────
|
|
2309
|
+
{
|
|
2310
|
+
eq(GATES.length, 67, 'gate count');
|
|
2311
|
+
eq(GATES.filter((g) => !g.advisory).length, 61, 'hard gate count');
|
|
2312
|
+
eq(GATES.filter((g) => g.advisory).length, 6, 'advisory gate count');
|
|
2313
|
+
ok(!GATES.find((g) => g.id === 'boundary-contrast').advisory, 'boundary-contrast is hard');
|
|
2314
|
+
ok(GATES.find((g) => g.id === 'boundary-contrast-episode').advisory, 'boundary-contrast-episode is advisory');
|
|
2315
|
+
eq(DEFAULT_PARAMS.boundaryContrastCuts, 2, 'boundaryContrastCuts default');
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// ── boundary-contrast (跨场边界) ─────────────────────────────────────────────
|
|
2319
|
+
{
|
|
2320
|
+
passes('boundary-contrast', PROJECT, E01);
|
|
2321
|
+
// E01-01 is scene 1, E01-02 opens scene 2: copy scene 1's last cut signature onto scene 2's first cut
|
|
2322
|
+
const last = E01.storyboard.segments[0].cuts.at(-1);
|
|
2323
|
+
const same = (c) => Object.assign(c, { size: last.size, angle: last.angle, characters: [...last.characters] });
|
|
2324
|
+
const e = clone(E01); same(e.storyboard.segments[1].cuts[0]);
|
|
2325
|
+
breaks('boundary-contrast', PROJECT, e, 'across the scene 1→2 boundary');
|
|
2326
|
+
const e2 = clone(e); e2.storyboard.segments[1].cuts[0].note = 'match-cut on her hand';
|
|
2327
|
+
passes('boundary-contrast', PROJECT, e2);
|
|
2328
|
+
const e3 = clone(e); e3.storyboard.segments[0].cuts.at(-1).note = 'Match Cut';
|
|
2329
|
+
passes('boundary-contrast', PROJECT, e3);
|
|
2330
|
+
// K decides how deep the comparison reaches: a match one cut in only counts when K ≥ 2
|
|
2331
|
+
const e4 = clone(E01); same(e4.storyboard.segments[1].cuts[1]);
|
|
2332
|
+
breaks('boundary-contrast', PROJECT, e4, 'cut 2');
|
|
2333
|
+
const p1 = clone(PROJECT); p1.params.boundaryContrastCuts = 1;
|
|
2334
|
+
passes('boundary-contrast', p1, e4);
|
|
2335
|
+
const p9 = clone(PROJECT); p9.params.boundaryContrastCuts = 9; // clamps to 3
|
|
2336
|
+
const e5 = clone(E01); same(e5.storyboard.segments[1].cuts[2]);
|
|
2337
|
+
breaks('boundary-contrast', p9, e5, 'cut 3');
|
|
2338
|
+
passes('boundary-contrast', PROJECT, e5);
|
|
2339
|
+
const pBad = clone(PROJECT); pBad.params.boundaryContrastCuts = 'lots'; // falls back to 2
|
|
2340
|
+
breaks('boundary-contrast', pBad, e4, 'cut 2');
|
|
2341
|
+
const e6 = clone(e); e6.storyboard.segments.forEach((s) => delete s.sceneIndex);
|
|
2342
|
+
passes('boundary-contrast', PROJECT, e6);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// ── boundary-contrast-episode (跨集边界, advisory) ───────────────────────────
|
|
2346
|
+
{
|
|
2347
|
+
const e02 = clone(E01);
|
|
2348
|
+
e02.storyboard.segments.forEach((s, i) => { s.id = `E02-${String(i + 1).padStart(2, '0')}`; });
|
|
2349
|
+
const lastCut = E01.storyboard.segments.at(-1).cuts.at(-1);
|
|
2350
|
+
Object.assign(e02.storyboard.segments[0].cuts[0], { size: lastCut.size, angle: lastCut.angle, characters: [...lastCut.characters] });
|
|
2351
|
+
const g = GATES.find((x) => x.id === 'boundary-contrast-episode');
|
|
2352
|
+
const notes = g.run(makeContext(PROJECT, { 1: E01, 2: e02 }));
|
|
2353
|
+
ok(notes.length > 0 && notes.some((m) => m.includes('across episode E01→E02')), `episode boundary noted: ${notes.join(' | ')}`);
|
|
2354
|
+
const results = runGates(makeContext(PROJECT, { 1: E01, 2: e02 }), { only: ['storyboard'] });
|
|
2355
|
+
const r = results.find((x) => x.id === 'boundary-contrast-episode');
|
|
2356
|
+
ok(r.advisory && r.failures.length === 0 && r.warnings.length > 0, 'episode boundary is a warning, never a failure');
|
|
2357
|
+
e02.storyboard.segments[0].cuts[0].note = 'match-cut';
|
|
2358
|
+
eq(g.run(makeContext(PROJECT, { 1: E01, 2: e02 })).length, 0, 'match-cut exempts the episode boundary');
|
|
2359
|
+
eq(g.run(makeContext(PROJECT, { 1: E01 })).length, 0, 'one episode has no episode boundary');
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// ── refOverride / faceOf (Director pins the picture references) ──────────────
|
|
2363
|
+
{
|
|
2364
|
+
const sheets = clone(PROJECT); sheets.params.references = 'sheets';
|
|
2365
|
+
const e = clone(E01);
|
|
2366
|
+
const seg = e.storyboard.segments[1]; // S01 with C01 + C02 + P01
|
|
2367
|
+
const derived = segmentReferences(sheets, e, seg);
|
|
2368
|
+
eq(derived.filter((r) => r.kind === 'image').map((r) => r.entity).join(','), 'S01,C01,C02,P01', 'derived picture order: environment, people, props');
|
|
2369
|
+
seg.refOverride = [{ entity: 'C01', asset: 'headshot', faceOf: 'C01' }, { entity: 'S01' }];
|
|
2370
|
+
const refs = segmentReferences(sheets, e, seg);
|
|
2371
|
+
const pics = refs.filter((r) => r.kind === 'image');
|
|
2372
|
+
eq(pics.map((r) => `${r.entity}:${r.asset}:${r.label}`).join(' '), 'C01:headshot:@image1 S01:sheet:@image2', 'refOverride replaces the pictures, in its own order');
|
|
2373
|
+
eq(pics[0].faceOf, 'C01', 'faceOf rides along');
|
|
2374
|
+
eq(pics[1].why, 'S01:sheet', 'override defaults fill why');
|
|
2375
|
+
eq(refs.filter((r) => r.kind === 'audio').length, derived.filter((r) => r.kind === 'audio').length, 'voices are still appended after an override');
|
|
2376
|
+
const sk = promptSkeleton(sheets, e, 'E01-02');
|
|
2377
|
+
ok(sk.video.prompt.includes('the person in @image1'), 'the speaker binds to the headshot label');
|
|
2378
|
+
// A body sheet first, the headshot later: the headshot still wins the face binding
|
|
2379
|
+
seg.refOverride = [{ entity: 'C01', asset: 'sheet' }, { entity: 'C01', asset: 'headshot', faceOf: 'C01' }, { entity: 'S01' }];
|
|
2380
|
+
const sk2 = promptSkeleton(sheets, e, 'E01-02');
|
|
2381
|
+
ok(sk2.video.prompt.includes('the person in @image2'), 'headshot / faceOf beats an earlier body sheet');
|
|
2382
|
+
seg.refOverride = [];
|
|
2383
|
+
eq(segmentReferences(sheets, e, seg).filter((r) => r.kind === 'image')[0].entity, 'S01', 'an empty override falls back to the derived set');
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// ── VO lines: off-screen, no @audio, every visible mouth closed ───────────────
|
|
2387
|
+
{
|
|
2388
|
+
const vo = (project) => {
|
|
2389
|
+
const e = clone(E01);
|
|
2390
|
+
e.scenes[1].flow[2].speaker = 'VO'; // beat 3, claimed by E01-02 cut 3 where C01 and C02 are on screen
|
|
2391
|
+
const sk = promptSkeleton(project, e, 'E01-02');
|
|
2392
|
+
const line = sk.dialogue.flat().find((l) => l.speaker === 'VO');
|
|
2393
|
+
ok(line && /off-screen/.test(line.speakerRef), 'VO speaker is described as off-screen');
|
|
2394
|
+
ok(sk.video.prompt.includes('off-screen'), 'the prompt says the voice is off-screen');
|
|
2395
|
+
ok(sk.video.prompt.includes(`do not speak, ${LIPS_CLOSED}`), 'both visible people keep their lips closed under a VO');
|
|
2396
|
+
ok(audioReferences(project, e, e.storyboard.segments[1]).every((r) => r.entity !== 'VO'), 'VO gets no voice reference');
|
|
2397
|
+
eq(claimedLines(e.scenes[1], e.storyboard.segments[1].cuts).filter((l) => l.speaker === 'VO').length, 1, 'the VO line is still claimed by its cut');
|
|
2398
|
+
return e;
|
|
2399
|
+
};
|
|
2400
|
+
const eVo = vo(PROJECT); // h3 / keyframes branch
|
|
2401
|
+
const sheets = clone(PROJECT); sheets.params.references = 'sheets'; vo(sheets); // sheets branch
|
|
2402
|
+
const seedance = clone(PROJECT); seedance.params.video = 'seedance'; seedance.params.references = 'sheets'; vo(seedance);
|
|
2403
|
+
passes('beat-flow', PROJECT, eVo);
|
|
2404
|
+
const eBad = clone(E01); eBad.scenes[1].flow[2].speaker = 'C03';
|
|
2405
|
+
breaks('beat-flow', PROJECT, eBad, 'not in this scene (or VO)');
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
// ── one-episode structure ───────────────────────────────────────────────────
|
|
2409
|
+
{
|
|
2410
|
+
const one = clone(PROJECT);
|
|
2411
|
+
one.params.episodes = 1;
|
|
2412
|
+
one.outline.episodes = one.outline.episodes.slice(0, 1);
|
|
2413
|
+
const golden = { ...one.outline.structure[0], episodes: [1, 1] };
|
|
2414
|
+
const climax = { ...one.outline.structure[2], episodes: [1, 1] };
|
|
2415
|
+
one.outline.structure = [golden, climax];
|
|
2416
|
+
passes('structure', one, null);
|
|
2417
|
+
const p2 = clone(one); p2.outline.structure = [golden];
|
|
2418
|
+
breaks('structure', p2, null, 'golden → climax');
|
|
2419
|
+
const p3 = clone(one); p3.outline.structure = [{ ...golden, episodes: [1, 2] }, climax];
|
|
2420
|
+
breaks('structure', p3, null, 'inside 1–1');
|
|
2421
|
+
const p4 = clone(one); p4.params.episodes = 2; p4.outline.episodes = PROJECT.outline.episodes.slice(0, 2);
|
|
2422
|
+
p4.outline.structure = [golden, { ...climax, episodes: [2, 2] }];
|
|
2423
|
+
passes('structure', p4, null);
|
|
2424
|
+
const p5 = clone(p4); p5.outline.structure = [{ ...golden, episodes: [1, 1] }, { ...climax, episodes: [1, 2] }];
|
|
2425
|
+
breaks('structure', p5, null, 'expected 2');
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// ── formatGates (what the CLI prints and what wji-backend embeds) ─────────────
|
|
2429
|
+
{
|
|
2430
|
+
const results = runGates(makeContext(PROJECT, { 1: E01 }));
|
|
2431
|
+
const rep = formatGates(results);
|
|
2432
|
+
eq(Object.keys(rep).sort().join(','), 'advisory,failed,hard,lines,notes,summary,text', 'formatGates shape');
|
|
2433
|
+
eq(rep.summary, '✓ 61 gate(s) passed, 6 advisory (1 note)', 'fixture summary');
|
|
2434
|
+
eq(rep.failed, 0, 'nothing failed'); eq(rep.hard, 61, 'hard count'); eq(rep.advisory, 6, 'advisory count');
|
|
2435
|
+
// The fixture now raises one advisory (E01-03 carries the 爽点 and is not cut as faceslap),
|
|
2436
|
+
// exactly as it does in the wji-cli copy — advisory lines print, failures still must not.
|
|
2437
|
+
eq(rep.lines.filter((l) => l.includes('✗')).length, 0, 'a green run prints no failed gates');
|
|
2438
|
+
eq(rep.lines.length, 2, 'a green run prints only the advisory and its note');
|
|
2439
|
+
ok(rep.text.endsWith(rep.summary), 'text ends with the summary');
|
|
2440
|
+
const staged = formatGates(runGates(makeContext(PROJECT, {}), { stage: 'art' }), { stage: 'art' });
|
|
2441
|
+
ok(staged.summary.includes('(through art)'), 'staged summary names the stage');
|
|
2442
|
+
const all = formatGates(results, { all: true });
|
|
2443
|
+
ok(all.lines.length >= 67 && all.lines.some((l) => l.includes('(advisory)')), '--all lists every gate');
|
|
2444
|
+
ok(formatGates(runGates(makeContext(PROJECT, {})), { all: true }).lines.some((l) => l.includes('(skipped: no episode files)')), '--all shows skipped gates');
|
|
2445
|
+
const bad = clone(PROJECT); bad.params.register = '';
|
|
2446
|
+
const red = formatGates(runGates(makeContext(bad, { 1: E01 })));
|
|
2447
|
+
ok(red.failed > 0 && red.summary.startsWith('✗') && red.text.includes('outline/params'), 'a broken register shows as a failed outline/params gate');
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// ── names in the prompt-bound voice / delivery text; disguised characters ─────
|
|
2451
|
+
{
|
|
2452
|
+
const p = clone(PROJECT);
|
|
2453
|
+
p.characters[1].voice.emotion += ',对小夏说话时放轻';
|
|
2454
|
+
breaks('voice', p, null, 'voice.emotion contains the name "小夏"');
|
|
2455
|
+
const p2 = clone(PROJECT);
|
|
2456
|
+
p2.characters[0].voice.sample.direction = '盯着老陈,压低声音';
|
|
2457
|
+
breaks('voice', p2, null, 'voice.sample.direction contains the name "老陈"');
|
|
2458
|
+
const p3 = clone(PROJECT);
|
|
2459
|
+
p3.characters[0].voice.prompt += ' Speaks softly to Chen.';
|
|
2460
|
+
passes('voice', p3); // an English word that is no name of the cast
|
|
2461
|
+
p3.characters[1].aliases.push('Uncle Chen');
|
|
2462
|
+
p3.characters[0].voice.prompt += ' Warm with Uncle Chen.';
|
|
2463
|
+
breaks('voice', p3, null, 'voice.prompt contains the name "Uncle Chen"');
|
|
2464
|
+
|
|
2465
|
+
const wan = clone(PROJECT); wan.params.video = 'wan';
|
|
2466
|
+
passes('beat-flow', wan, E01);
|
|
2467
|
+
const e = clone(E01);
|
|
2468
|
+
e.scenes[0].flow[1].delivery = '压着嗓子,盯着林小夏';
|
|
2469
|
+
breaks('beat-flow', wan, e, 'delivery names "林小夏"');
|
|
2470
|
+
passes('beat-flow', PROJECT, e); // an English (h3) prompt never carries a Chinese delivery
|
|
2471
|
+
const eEn = clone(E01);
|
|
2472
|
+
eEn.scenes[0].flow[1].delivery = 'hisses it at Uncle Chen';
|
|
2473
|
+
breaks('beat-flow', p3, eEn, 'delivery names "Uncle Chen"');
|
|
2474
|
+
|
|
2475
|
+
const d = clone(PROJECT);
|
|
2476
|
+
d.characters[2].disguise = { name: '过路人', revealEpisode: 3 };
|
|
2477
|
+
passes('tiers', d);
|
|
2478
|
+
eq(displayName(d, 'C03', 1), '过路人', 'before the reveal the disguise name is shown');
|
|
2479
|
+
eq(displayName(d, 'C03', 3), '穿雨衣的男人', 'from the reveal episode on the real name is shown');
|
|
2480
|
+
eq(displayName(d, 'C03'), '穿雨衣的男人', 'without an episode the real name is shown');
|
|
2481
|
+
eq(displayName(d, 'C01', 1), '林小夏', 'undisguised characters keep their name');
|
|
2482
|
+
ok(forbiddenNames(d).includes('过路人'), 'a disguise name is a forbidden prompt name');
|
|
2483
|
+
const script = renderScript(d, E01);
|
|
2484
|
+
ok(script.includes('过路人(') && !script.includes('穿雨衣的男人('), 'render script attributes lines to the disguise name');
|
|
2485
|
+
ok(/人物:[^\n]*过路人/.test(script), 'render script lists the disguise name in the scene cast');
|
|
2486
|
+
ok(renderDialogue(d, E01).includes('过路人(') && !renderDialogue(d, E01).includes('穿雨衣的男人('), 'render dialogue uses the disguise name');
|
|
2487
|
+
eq(seedScript(d, 1).seedNote.speech.C03.name, '过路人', 'seed note uses the disguise name');
|
|
2488
|
+
const late = clone(E01); late.ep = 3;
|
|
2489
|
+
ok(renderScript(d, late).includes('穿雨衣的男人('), 'after the reveal the script uses the real name');
|
|
2490
|
+
for (const [bad, needle] of [[{ name: '过路人', revealEpisode: 1 }, 'revealEpisode'], [{ name: '穿雨衣的男人', revealEpisode: 2 }, 'equals the real name'], [{ revealEpisode: 2 }, 'disguise.name is empty'], [{ name: '过路人', revealEpisode: 9 }, 'revealEpisode']]) {
|
|
2491
|
+
const x = clone(PROJECT); x.characters[2].disguise = bad;
|
|
2492
|
+
breaks('tiers', x, null, needle);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// ── Chinese image prompts (params.imagePromptLang = zh) ──────────────────────
|
|
2497
|
+
{
|
|
2498
|
+
// the fixture with its image prompts written in Chinese; C02 in 繁體 to prove the folding
|
|
2499
|
+
const zh = clone(PROJECT);
|
|
2500
|
+
zh.params.imagePromptLang = 'zh';
|
|
2501
|
+
const R = '半写实角色插画,绘画质感,笔触柔和融合并带可见的笔刷肌理,解剖结构扎实';
|
|
2502
|
+
const NEG = '眼神空洞、塑料皮肤、过度磨皮、完全对称的脸、死板发型、无纹理布料、标志、品牌名、文字、水印';
|
|
2503
|
+
const CH = {
|
|
2504
|
+
C01: ['二十四岁左右的东亚女性,当代中国,便利店夜班店员,短发齐耳,褪色的藏青夹克,眼下微青,四分之三侧身胸像,纯色中性背景,左上方柔和方向光',
|
|
2505
|
+
'单人角色设定图,一张 16:9 横版画布,纯白背景。' + R + '。左区一张正面胸像,右上三个全身像(正面、侧面、背面),右下四个细节特写;人物:二十四岁东亚女性,短发,藏青夹克。无文字、无水印。'],
|
|
2506
|
+
C02: ['六十歲出頭的東亞男性,當代中國,退休保安,矮壯微駝,花白平頭,洗舊的灰色夾克,指節粗大,四分之三側身胸像,純色中性背景,左上方柔和方向光',
|
|
2507
|
+
'單人角色設定圖,一張 16:9 橫版畫布,純白背景。半寫實角色插畫,繪畫質感,筆觸柔和。左區一張正面胸像,右上三個全身像,右下四個細節特寫;人物:六十歲東亞男性,花白平頭,灰夾克。無文字、無水印。'],
|
|
2508
|
+
C03: ['三十多岁的东亚男性,当代中国,穿深色连帽雨衣,帽檐压低,下巴有胡茬,身形瘦高,四分之三侧身胸像,纯色中性背景,右侧硬光',
|
|
2509
|
+
'单人角色设定图,一张 16:9 横版画布,纯白背景。' + R + '。左区一张正面胸像,右上三个全身像,右下四个细节特写;人物:三十多岁东亚男性,深色雨衣,瘦高。无文字、无水印。'],
|
|
2510
|
+
};
|
|
2511
|
+
for (const c of zh.characters) Object.assign(c.image, { prompt: CH[c.id][0], sheet: CH[c.id][1], negativePrompt: NEG });
|
|
2512
|
+
const SC = {
|
|
2513
|
+
S01: ['中国城市里一家小便利店的店内,空场景,没有人,从门口望向收银台,关东煮机的灯箱闪烁,绿色四格监控屏,夜晚冷白日光灯', { 凌晨冷白光: '凌晨,整片冷白日光灯,货架投下硬影', 监控回放绿: '只剩监控屏的绿光照亮收银台,其余沉在暗里' }],
|
|
2514
|
+
S02: ['便利店门口的条纹雨棚下,空场景,没有人,湿漉漉的地面反光,门口的垃圾桶,雨夜', { 雨夜冷白灯: '雨夜,雨棚下一盏冷白灯,雨丝在灯下发亮' }],
|
|
2515
|
+
};
|
|
2516
|
+
for (const sc of zh.scenes) {
|
|
2517
|
+
Object.assign(sc.image, { prompt: SC[sc.id][0], sheet: `环境设定图,一张 16:9 横版画布。${R}。主画面:${SC[sc.id][0]};右侧与底部为锚点细节特写,所有格子是同一个空间。无文字、无水印。`, negativePrompt: '人、人物、人影、人群、手、标志、品牌名、文字、水印' });
|
|
2518
|
+
for (const l of sc.lighting) l.prompt = SC[sc.id][1][l.state];
|
|
2519
|
+
}
|
|
2520
|
+
const pr = zh.props[0];
|
|
2521
|
+
Object.assign(pr.image, { prompt: '一个旧的三点五寸监控硬盘,手持尺寸,金属外壳有划痕,贴着褪色的标签(不可辨读),纯白背景,没有人,没有手,柔和均匀的棚拍光',
|
|
2522
|
+
sheet: `道具设定图,一张 16:9 横版画布,纯白背景。${R}。主画面:旧监控硬盘,手持尺寸,四分之三视角;右侧与底部为细节特写。没有人、没有手。无文字、无水印。`,
|
|
2523
|
+
negativePrompt: '人、人物、手、手指、杂乱背景、标志、品牌名、文字、水印' });
|
|
2524
|
+
pr.states[0].prompt = '装在半透明的防静电袋里,袋口折起';
|
|
2525
|
+
pr.states[1].prompt = '接上一根灰色数据线,指示灯亮着';
|
|
2526
|
+
for (const g of ['params', 'char-prompts', 'distinct', 'scene-design', 'scene-prompts', 'prop-design']) passes(g, zh);
|
|
2527
|
+
ok(IMAGE_PROMPT_LANGS.includes('zh') && imagePromptKit(zh.params).zh, 'zh is an image-prompt language');
|
|
2528
|
+
eq(foldZh('真人實拍參考攝影'), '真人实拍参考摄影', 'traditional folds to simplified');
|
|
2529
|
+
|
|
2530
|
+
const en = clone(PROJECT); en.params.imagePromptLang = 'zh';
|
|
2531
|
+
breaks('char-prompts', en, null, 'image.prompt must be Chinese');
|
|
2532
|
+
breaks('scene-prompts', en, null, 'must be Chinese');
|
|
2533
|
+
breaks('scene-design', en, null, 'prompt must be Chinese');
|
|
2534
|
+
breaks('prop-design', en, null, 'must be Chinese');
|
|
2535
|
+
const fr = clone(PROJECT); fr.params.imagePromptLang = 'fr';
|
|
2536
|
+
breaks('params', fr, null, 'imagePromptLang');
|
|
2537
|
+
|
|
2538
|
+
const x = clone(zh);
|
|
2539
|
+
x.characters[0].image.prompt = x.characters[0].image.prompt.replace('东亚女性,当代中国', '女性,当代');
|
|
2540
|
+
breaks('char-prompts', x, null, 'ethnicity');
|
|
2541
|
+
const bright = clone(zh);
|
|
2542
|
+
bright.characters[1].image.prompt = '东亚男性,退休保安,明亮清晰的元素,矮壮微驼'; // 明 / 清 / 元 alone are no era
|
|
2543
|
+
breaks('char-prompts', bright, null, 'era');
|
|
2544
|
+
x.characters[1].image.prompt = x.characters[1].image.prompt.replace('當代中國,', '');
|
|
2545
|
+
breaks('char-prompts', x, null, 'era');
|
|
2546
|
+
x.characters[2].image.sheet = x.characters[2].image.sheet.replace(R, '写实风格');
|
|
2547
|
+
breaks('char-prompts', x, null, 'render phrase "半写实角色插画…"');
|
|
2548
|
+
x.characters[0].image.negativePrompt += '、照片級';
|
|
2549
|
+
breaks('char-prompts', x, null, 'must not ban "照片级"');
|
|
2550
|
+
x.characters[0].image.sheet = x.characters[0].image.sheet.replace('16:9', '横版');
|
|
2551
|
+
breaks('char-prompts', x, null, '16:9');
|
|
2552
|
+
const y = clone(zh);
|
|
2553
|
+
y.scenes[0].image.prompt = y.scenes[0].image.prompt.replace('空场景,没有人,', '');
|
|
2554
|
+
breaks('scene-prompts', y, null, 'empty');
|
|
2555
|
+
y.scenes[1].image.negativePrompt = '标志、文字、水印';
|
|
2556
|
+
breaks('scene-prompts', y, null, 'ban people');
|
|
2557
|
+
const z = clone(zh);
|
|
2558
|
+
z.props[0].image.prompt = z.props[0].image.prompt.replace('手持尺寸,', '');
|
|
2559
|
+
breaks('prop-design', z, null, 'must contain "手持尺寸"');
|
|
2560
|
+
z.props[0].image.sheet = z.props[0].image.sheet.replace('纯白背景', '灰色背景');
|
|
2561
|
+
breaks('prop-design', z, null, 'white background');
|
|
2562
|
+
z.props[0].image.negativePrompt = '人、标志、文字';
|
|
2563
|
+
breaks('prop-design', z, null, 'ban hands');
|
|
2564
|
+
const d = clone(zh);
|
|
2565
|
+
d.characters[2].image.prompt = d.characters[0].image.prompt;
|
|
2566
|
+
breaks('distinct', d, null, 'image prompts are 100% alike'); // CJK bigrams, not English words
|
|
2567
|
+
|
|
2568
|
+
const head = imagePrompt(zh, 'C01', 'headshot');
|
|
2569
|
+
ok(head.prompt.endsWith('正面干净人像,纯色中性背景') && head.ratio === '1:1' && head.from === null && head.lang === 'zh', 'zh headshot prompt');
|
|
2570
|
+
const sheet = imagePrompt(zh, 'C01', 'sheet');
|
|
2571
|
+
ok(sheet.prompt.startsWith('@image1 是必须完全一致的脸') && sheet.ratio === '16:9' && sheet.from === 'headshot' && sheet.negative === NEG, 'zh character sheet references the headshot');
|
|
2572
|
+
const light = imagePrompt(zh, 'S01', 'lighting:监控回放绿');
|
|
2573
|
+
ok(light.prompt.includes('只改变光线:只剩监控屏的绿光') && light.prompt.endsWith('空场景,没有任何人,电影剧照') && light.from === 'sheet', 'zh lighting variant');
|
|
2574
|
+
ok(imagePrompt(zh, 'P01', 'state:接在主机上').prompt.startsWith('@image1 是要完全还原的同一件道具。接上一根灰色数据线'), 'zh prop state');
|
|
2575
|
+
eq(imagePrompt(zh, 'S02').prompt, zh.scenes[1].image.sheet, 'a scene sheet is its sheet prompt');
|
|
2576
|
+
ok(imagePrompt(PROJECT, 'C01', 'sheet').prompt.startsWith('@image1 is the face to match exactly'), 'en character sheet keeps the English prefix');
|
|
2577
|
+
ok(imagePrompt(zh, 'C02', 'sheet').prompt.startsWith('@image1 是必須完全一致的臉——五官與髮型相同。'), '繁體 prompts get the 繁體 prefix');
|
|
2578
|
+
ok(imagePrompt(zh, 'C02', 'headshot').prompt.endsWith(',正面乾淨人像,純色中性背景'), '繁體 headshot suffix');
|
|
2579
|
+
ok(imagePrompt(PROJECT, 'S01', 'lighting:凌晨冷白光').prompt.endsWith('Empty scene, no people, cinematic film still'), 'en lighting variant');
|
|
2580
|
+
assert.throws(() => imagePrompt(zh, 'S01', 'lighting:白天'), /no lighting state/, 'unknown lighting state refused');
|
|
2581
|
+
assert.throws(() => imagePrompt(zh, 'C01', 'lighting:x'), /headshot \| sheet/, 'a character has no lighting');
|
|
2582
|
+
assert.throws(() => imagePrompt(zh, 'X9'), /no character/, 'unknown id refused');
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// ── the CLI entry point survives a symlink (realpath isMain) ──────────────────
|
|
2586
|
+
{
|
|
2587
|
+
const dir = mkdtempSync(join(tmpdir(), 'sd-link-'));
|
|
2588
|
+
try {
|
|
2589
|
+
copyFileSync(join(here, 'short-drama.mjs'), join(dir, 'engine.mjs'));
|
|
2590
|
+
symlinkSync(join(dir, 'engine.mjs'), join(dir, 'sd'));
|
|
2591
|
+
const run = spawnSync(process.execPath, [join(dir, 'sd')], { encoding: 'utf8' });
|
|
2592
|
+
eq(run.status, 0, 'symlinked engine exits 0 with usage');
|
|
2593
|
+
ok(run.stdout.includes('short-drama — deterministic tooling'), 'symlinked engine prints usage (isMain resolved through realpath)');
|
|
2594
|
+
} finally {
|
|
2595
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2301
2599
|
console.log(`✓ ${passed} assertions passed`);
|