@wji.ai/cli 0.8.0 → 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 CHANGED
@@ -292,6 +292,10 @@ var MIME_TYPES = {
292
292
  pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
293
293
  };
294
294
  var mimeTypeOf = (filename) => MIME_TYPES[filename.split(".").pop()?.toLowerCase() || ""] || "application/octet-stream";
295
+ function clientVersion(version, env = process.env) {
296
+ const agent = (env.WJI_AGENT || "").trim() || (env.CLAUDECODE === "1" ? "claude-code" : "");
297
+ return agent ? `${version}; agent=${agent.replace(/[^\w.@-]/g, "_").slice(0, 40)}` : version;
298
+ }
295
299
  var ApiError = class extends Error {
296
300
  constructor(message, status, path, body) {
297
301
  super(message);
@@ -346,6 +350,8 @@ var ApiClient = class {
346
350
  headers(opts) {
347
351
  const headers = {
348
352
  Accept: "application/json",
353
+ "X-Client": this.spec.client ?? "cli",
354
+ "X-Client-Version": clientVersion(this.spec.version),
349
355
  "X-Locale": this.globals.locale || readEnv(this.spec, "LOCALE") || this.profile.locale || "cht"
350
356
  };
351
357
  if (!opts.anonymous) headers.Authorization = `Bearer ${this.requireToken()}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wji.ai/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Command line interface for the WJI AI platform",
5
5
  "license": "UNLICENSED",
6
6
  "author": "WJI AI",
@@ -30,17 +30,16 @@
30
30
  "engines": {
31
31
  "node": ">=20"
32
32
  },
33
- "scripts": {
34
- "build": "tsup && chmod +x dist/index.js",
35
- "dev": "tsup --watch",
36
- "typecheck": "tsc -p tsconfig.json --noEmit",
37
- "start": "node dist/index.js",
38
- "prepublishOnly": "pnpm run build"
39
- },
40
33
  "dependencies": {
41
34
  "commander": "^12.1.0"
42
35
  },
43
36
  "publishConfig": {
44
37
  "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup && chmod +x dist/index.js",
41
+ "dev": "tsup --watch",
42
+ "typecheck": "tsc -p tsconfig.json --noEmit",
43
+ "start": "node dist/index.js"
45
44
  }
46
- }
45
+ }
@@ -159,6 +159,30 @@ write JSON → validate --stage <stage> → fix until green → render → sync
159
159
  approving a sheet or moving to the next segment. The reference-repo lesson: one wrong sheet
160
160
  corrupts every shot after it.
161
161
 
162
+ ## Production dashboard
163
+
164
+ Generate the dashboard whenever the user wants to review the project, and refresh it after a
165
+ stage or episode is locked:
166
+
167
+ ```bash
168
+ node {baseDir}/scripts/short-drama.mjs dashboard {project}
169
+ # custom location, and a folder of downloaded WJI images:
170
+ node {baseDir}/scripts/short-drama.mjs dashboard {project} \
171
+ --output {project}/dashboard.html --assets {project}/assets --force
172
+ ```
173
+
174
+ The output is one static HTML file: production totals and gate health, brief and guideline,
175
+ episode directory, character bibles, scene / prop sheets, scripts, cut timing and storyboard
176
+ plans. It does not call WJI and costs no credits. Every character, scene, prop and storyboard
177
+ slot keeps an image placeholder until a picture or video is bound. A bound value may be a WJI
178
+ server URL (`https://…` image or video) or a local file: set `image.url` / `video.url`, or
179
+ `wji.assets.sheet|headshot|keyframe|video`, to that URL or to a path relative to the project
180
+ (or an absolute path). Files whose name contains the entity or asset id are also picked up from
181
+ `{project}/assets`, `{project}/output`, the project tree, or `--assets <dir>`. A video URL or
182
+ `.mp4` / `.webm` / `.mov` file renders as a player; if the address fails to load, the
183
+ placeholder stays. Open `dashboard.html` in a browser. Do not paste the HTML into a dataset or
184
+ upload it as an asset.
185
+
162
186
  ## Stage 1 — Outline
163
187
 
164
188
  Read `{baseDir}/reference/stage-outline.md`.
@@ -20,6 +20,7 @@ node {baseDir}/scripts/short-drama.mjs <cmd> {project} …
20
20
  | `checkup` | `short-drama.mjs checkup {project}` — same, but prints the passing and skipped rows too |
21
21
  | `seed` | `short-drama.mjs seed {project} script --ep N [--force]` · `… seed {project} storyboard --ep N [--force]` |
22
22
  | `render` | `short-drama.mjs render {project} outline \| guideline \| character C01 \| scene S01 \| prop P01 \| script --ep N \| dialogue --ep N \| segment E01-01` |
23
+ | `dashboard` | `short-drama.mjs dashboard {project} [--output dashboard.html] [--assets <dir>] [--force]` — writes one self-contained production dashboard; each visual slot keeps a placeholder, and accepts a WJI image/video URL or a local file |
23
24
  | `prompt` | `short-drama.mjs prompt {project} segment E01-01 [--json]` · `… prompt {project} audio E01-01 [--full\|--bed]` · `… prompt {project} score E01 [--fps 24] [--json]` (post soundtrack spotting sheet) · `… prompt {project} voice C01` |
24
25
  | `manifest` | `short-drama.mjs manifest {project} [--stage <stage>] [--ep N] [--json]` — exit 2 while anything is unbound |
25
26
  | `bind` | `short-drama.mjs bind {project} [--ep N] <path> <value>` — record a WJI id in the JSON |
@@ -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,
@@ -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 }));
@@ -0,0 +1,459 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.avif'])
6
+ const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.m4v'])
7
+
8
+ function esc(value) {
9
+ return String(value ?? '')
10
+ .replaceAll('&', '&amp;')
11
+ .replaceAll('<', '&lt;')
12
+ .replaceAll('>', '&gt;')
13
+ .replaceAll('"', '&quot;')
14
+ .replaceAll("'", '&#39;')
15
+ }
16
+
17
+ function text(value, fallback = '—') {
18
+ const out = String(value ?? '').trim()
19
+ return out || fallback
20
+ }
21
+
22
+ function list(value) {
23
+ return Array.isArray(value) ? value.filter(Boolean) : []
24
+ }
25
+
26
+ function sum(items, pick = (it) => it) {
27
+ return items.reduce((total, item) => total + (Number(pick(item)) || 0), 0)
28
+ }
29
+
30
+ function pad2(value) {
31
+ return String(value).padStart(2, '0')
32
+ }
33
+
34
+ function isMediaFile(name) {
35
+ const ext = extname(name).toLowerCase()
36
+ return IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext)
37
+ }
38
+
39
+ function walkImages(root, files = [], depth = 0) {
40
+ if (!root || !existsSync(root) || depth > 6) return files
41
+ for (const name of readdirSync(root)) {
42
+ if (name.startsWith('.') || name === 'node_modules') continue
43
+ const path = join(root, name)
44
+ const stats = statSync(path)
45
+ if (stats.isDirectory()) walkImages(path, files, depth + 1)
46
+ else if (isMediaFile(name)) files.push(path)
47
+ }
48
+ return files
49
+ }
50
+
51
+ function imageIndex(roots) {
52
+ const files = [...new Set(roots.flatMap((root) => walkImages(root)))]
53
+ return files.map((path) => ({
54
+ path,
55
+ key: basename(path, extname(path)).toLowerCase(),
56
+ full: path.toLowerCase(),
57
+ }))
58
+ }
59
+
60
+ function rawAsset(value) {
61
+ if (!value) return { source: '', kind: '' }
62
+ if (typeof value === 'object') {
63
+ return {
64
+ source: value.url || value.src || value.path || value.file || value.id || '',
65
+ kind: value.type || value.kind || '',
66
+ }
67
+ }
68
+ return { source: String(value), kind: '' }
69
+ }
70
+
71
+ function mediaKind(source, declared = '') {
72
+ const named = String(declared).toLowerCase()
73
+ if (named === 'video' || named === 'image') return named
74
+ const ext = extname(String(source).split(/[?#]/)[0]).toLowerCase()
75
+ return VIDEO_EXTENSIONS.has(ext) ? 'video' : 'image'
76
+ }
77
+
78
+ function localImageHref(path, outputPath) {
79
+ const rel = relative(dirname(outputPath), path)
80
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
81
+ return rel.split(sep).join('/')
82
+ }
83
+ return pathToFileURL(path).href
84
+ }
85
+
86
+ function resolveMedia(value, media) {
87
+ const { source, kind } = rawAsset(value)
88
+ if (!source) return null
89
+ if (/^(https?:|data:|file:)/i.test(source)) {
90
+ return { href: source, kind: mediaKind(source, kind) }
91
+ }
92
+ const localCandidates = [
93
+ isAbsolute(source) ? source : '',
94
+ join(media.projectDir, source),
95
+ join(media.projectDir, 'assets', source),
96
+ ].filter(Boolean)
97
+ for (const path of localCandidates) {
98
+ if (existsSync(path) && statSync(path).isFile()) {
99
+ return { href: localImageHref(path, media.outputPath), kind: mediaKind(path, kind) }
100
+ }
101
+ }
102
+ const lower = source.toLowerCase()
103
+ const found = media.files.find((item) => item.key === lower)
104
+ || media.files.find((item) => item.full.includes(lower))
105
+ if (!found) return null
106
+ return { href: localImageHref(found.path, media.outputPath), kind: mediaKind(found.path, kind) }
107
+ }
108
+
109
+ function entityMedia(entity, media) {
110
+ const assets = entity?.wji?.assets || {}
111
+ const candidates = [
112
+ typeof entity?.image === 'string' ? entity.image : '',
113
+ entity?.image?.url,
114
+ entity?.image?.src,
115
+ entity?.image?.path,
116
+ entity?.image?.file,
117
+ assets.sheet,
118
+ assets.headshot,
119
+ assets.keyframe,
120
+ assets.poster,
121
+ entity?.video,
122
+ assets.video,
123
+ ]
124
+ for (const candidate of candidates) {
125
+ const resolved = resolveMedia(candidate, media)
126
+ if (resolved) return resolved
127
+ }
128
+ const id = String(entity?.id || '').toLowerCase()
129
+ const hinted = media.files.find((item) => id && item.full.includes(id))
130
+ if (!hinted) return null
131
+ return { href: localImageHref(hinted.path, media.outputPath), kind: mediaKind(hinted.path) }
132
+ }
133
+
134
+ function badge(label, tone = '') {
135
+ return `<span class="badge ${tone}">${esc(label)}</span>`
136
+ }
137
+
138
+ function placeholder(label, hidden) {
139
+ return `<div class="visual-empty"${hidden ? ' hidden' : ''}>
140
+ <svg viewBox="0 0 64 48" aria-hidden="true"><rect x="1.5" y="1.5" width="61" height="45" rx="2"></rect><path d="M8 36l12-12 8 8 10-13 18 17"></path><circle cx="20" cy="15" r="4"></circle></svg>
141
+ <span>图片占位</span>
142
+ <small>${esc(label)} · 可填 WJI 图片 / 视频地址,或本地文件</small>
143
+ </div>`
144
+ }
145
+
146
+ function figure(resolved, alt, caption = '') {
147
+ const media = !resolved?.href
148
+ ? ''
149
+ : resolved.kind === 'video'
150
+ ? `<video src="${esc(resolved.href)}" controls preload="metadata" playsinline onerror="this.hidden=true;this.nextElementSibling.hidden=false"></video>`
151
+ : `<img src="${esc(resolved.href)}" alt="${esc(alt)}" loading="lazy" onerror="this.hidden=true;this.nextElementSibling.hidden=false">`
152
+ return `<figure class="visual">${media}${placeholder(alt, Boolean(resolved?.href))}${caption ? `<figcaption>${esc(caption)}</figcaption>` : ''}</figure>`
153
+ }
154
+
155
+ function sectionTitle(index, title, note = '') {
156
+ return `<header class="section-head"><span>${String(index).padStart(2, '0')}</span><h2>${esc(title)}</h2>${note ? `<p>${esc(note)}</p>` : ''}</header>`
157
+ }
158
+
159
+ function field(label, value) {
160
+ return `<div class="field"><dt>${esc(label)}</dt><dd>${esc(text(value))}</dd></div>`
161
+ }
162
+
163
+ function promptBlock(title, value) {
164
+ if (!String(value || '').trim()) return ''
165
+ return `<details class="prompt"><summary>${esc(title)}</summary><div>${esc(value)}</div></details>`
166
+ }
167
+
168
+ function overview(project, episodes, gateResults) {
169
+ const episodeList = Object.values(episodes)
170
+ const segments = episodeList.flatMap((episode) => list(episode.storyboard?.segments))
171
+ const cuts = segments.flatMap((segment) => list(segment.cuts))
172
+ const dialogue = episodeList.flatMap((episode) =>
173
+ list(episode.scenes).flatMap((scene) => list(scene.flow).filter((it) => it.speaker && it.line)),
174
+ )
175
+ const plannedSeconds = sum(cuts, (cut) => cut.seconds)
176
+ || sum(episodeList, (episode) => episode.targetSeconds)
177
+ const hard = gateResults.filter((gate) => !gate.skipped && !gate.advisory)
178
+ const failed = hard.filter((gate) => list(gate.failures).length)
179
+ const passed = hard.length - failed.length
180
+ const stats = [
181
+ ['集数', `${episodeList.length} / ${project.params?.episodes || 0}`],
182
+ ['人物', list(project.characters).length],
183
+ ['场景', list(project.scenes).length],
184
+ ['分镜段', segments.length],
185
+ ['镜头', cuts.length],
186
+ ['对白', `${dialogue.length} 句`],
187
+ ['计划时长', `${Math.round(plannedSeconds)} 秒`],
188
+ ['校验', failed.length ? `${passed}/${hard.length} 通过` : `${passed}/${hard.length} 通过`],
189
+ ]
190
+ return `
191
+ <section id="overview" class="page-section">
192
+ ${sectionTitle(1, '制作总览', '项目参数、生产规模与校验状态')}
193
+ <div class="stats">${stats.map(([label, value]) => `<div><strong>${esc(value)}</strong><span>${esc(label)}</span></div>`).join('')}</div>
194
+ <div class="overview-grid">
195
+ <article class="brief">
196
+ <h3>${esc(project.title)}</h3>
197
+ <p class="logline">${esc(text(project.outline?.brief?.logline || project.outline?.core))}</p>
198
+ <dl>
199
+ ${field('类型', project.params?.genre)}
200
+ ${field('语体', project.params?.register)}
201
+ ${field('画幅', project.params?.ratio)}
202
+ ${field('视频模式', project.params?.video)}
203
+ ${field('影像风格', project.params?.style)}
204
+ ${field('摄影基调', project.params?.cinematography)}
205
+ </dl>
206
+ </article>
207
+ <article class="gate-report">
208
+ <h3>阶段体检</h3>
209
+ ${failed.length
210
+ ? failed.slice(0, 8).map((gate) => `<div class="gate bad"><b>${esc(gate.stage)} / ${esc(gate.id)}</b><span>${esc(gate.label)}</span><small>${esc(gate.failures[0])}</small></div>`).join('')
211
+ : '<div class="all-clear"><b>全部硬性校验已通过</b><span>项目数据可以继续进入下一制作环节。</span></div>'}
212
+ </article>
213
+ </div>
214
+ </section>`
215
+ }
216
+
217
+ function story(project) {
218
+ const outline = project.outline || {}
219
+ const brief = outline.brief || {}
220
+ return `
221
+ <section id="story" class="page-section">
222
+ ${sectionTitle(2, '故事设计', '创作承诺、结构推进与剧集目录')}
223
+ <div class="story-grid">
224
+ <article class="story-core">
225
+ <h3>核心命题</h3>
226
+ <p>${esc(text(outline.core))}</p>
227
+ <dl>
228
+ ${field('观众', brief.audience)}
229
+ ${field('满足引擎', brief.satisfactionEngine)}
230
+ ${field('主钩子', brief.mainHook)}
231
+ ${field('情感线', brief.emotionalLine)}
232
+ ${field('兑现承诺', brief.promise)}
233
+ </dl>
234
+ </article>
235
+ <article class="guideline">
236
+ <h3>创作指引</h3>
237
+ <p>${esc(text(outline.guideline?.tone))}</p>
238
+ <dl>
239
+ ${field('情绪', outline.guideline?.emotionalIntensity)}
240
+ ${field('对白', outline.guideline?.dialogueStyle)}
241
+ ${field('视觉', outline.guideline?.visualStyle)}
242
+ ${field('限制', outline.guideline?.productionLimits)}
243
+ </dl>
244
+ </article>
245
+ </div>
246
+ <div class="episode-directory">
247
+ ${list(outline.episodes).map((episode) => `
248
+ <article>
249
+ <span class="episode-no">E${pad2(episode.ep)}</span>
250
+ <div>
251
+ <h3>${esc(list(episode.function).join(' / ') || `第 ${episode.ep} 集`)}</h3>
252
+ <p>${esc(text(episode.synopsis))}</p>
253
+ <footer>${badge(episode.hookType || '钩子', 'rust')}<span>${esc(text(episode.hook))}</span></footer>
254
+ </div>
255
+ </article>`).join('')}
256
+ </div>
257
+ </section>`
258
+ }
259
+
260
+ function characters(project, media) {
261
+ return `
262
+ <section id="characters" class="page-section">
263
+ ${sectionTitle(3, '人物设定', '人物弧、表演指引与视觉资产')}
264
+ <div class="character-list">
265
+ ${list(project.characters).map((character) => {
266
+ const profile = character.profile || {}
267
+ const bible = character.bible || {}
268
+ const visual = entityMedia(character, media)
269
+ return `<article class="character-card">
270
+ <div class="character-visual">${figure(visual, `${character.name} 角色设定图`)}</div>
271
+ <div class="character-copy">
272
+ <header><span>${esc(character.id)}</span><h3>${esc(character.name)}</h3>${badge(character.tier || 'character')}</header>
273
+ <p class="role">${esc(text(character.role))}</p>
274
+ <p class="one-liner">${esc(text(character.oneLiner))}</p>
275
+ <div class="character-columns">
276
+ <dl>
277
+ ${field('身份', profile.identity)}
278
+ ${field('年龄', profile.ageRange)}
279
+ ${field('外形', profile.appearance)}
280
+ ${field('性格', list(profile.personality).join('、'))}
281
+ ${field('动机', profile.motivation)}
282
+ </dl>
283
+ <dl>
284
+ ${field('想要', bible.want)}
285
+ ${field('需要', bible.need)}
286
+ ${field('误信', bible.misbelief)}
287
+ ${field('矛盾', bible.contradiction)}
288
+ ${field('人物弧', character.arc)}
289
+ </dl>
290
+ </div>
291
+ <blockquote>${esc(text(bible.hookLine || character.voice?.sample?.line))}</blockquote>
292
+ <div class="voice-line">${badge('声音', 'quiet')}<span>${esc([character.voice?.timbre, character.voice?.pace, character.voice?.accent].filter(Boolean).join(';') || '—')}</span></div>
293
+ ${promptBlock('角色图提示词', character.image?.prompt)}
294
+ ${promptBlock('角色设定图提示词', character.image?.sheet)}
295
+ </div>
296
+ </article>`
297
+ }).join('')}
298
+ </div>
299
+ </section>`
300
+ }
301
+
302
+ function world(project, media) {
303
+ const sceneCards = list(project.scenes).map((scene) => {
304
+ const visual = entityMedia(scene, media)
305
+ return `<article class="world-card">
306
+ <header><span>${esc(scene.id)}</span><h3>${esc(scene.name)}</h3>${scene.primary ? badge('主场景', 'rust') : ''}</header>
307
+ <p>${esc(text(scene.summary))}</p>
308
+ ${figure(visual, `${scene.name} 场景设定图`)}
309
+ <h4>视觉锚点</h4>
310
+ <ol>${list(scene.anchors).map((anchor) => `<li><b>${esc(anchor.name)}</b><span>${esc(anchor.desc)}</span></li>`).join('')}</ol>
311
+ <h4>光照状态</h4>
312
+ ${list(scene.lighting).map((state) => `<div class="lighting"><b>${esc(state.state)}</b><span>${esc(state.prompt)}</span></div>`).join('')}
313
+ ${promptBlock('场景主提示词', scene.image?.prompt)}
314
+ ${promptBlock('场景设定图提示词', scene.image?.sheet)}
315
+ </article>`
316
+ }).join('')
317
+ const propCards = list(project.props).map((prop) => {
318
+ const visual = entityMedia(prop, media)
319
+ return `<article class="world-card prop-card">
320
+ <header><span>${esc(prop.id)}</span><h3>${esc(prop.name)}</h3>${badge(prop.scale || '道具')}</header>
321
+ <p>${esc(text(prop.function || prop.summary))}</p>
322
+ ${figure(visual, `${prop.name} 道具设定图`)}
323
+ <h4>状态</h4>
324
+ ${list(prop.states).map((state) => `<div class="lighting"><b>${esc(state.state)}</b><span>${esc(state.prompt)}</span></div>`).join('')}
325
+ ${promptBlock('道具提示词', prop.image?.prompt)}
326
+ ${promptBlock('道具设定图提示词', prop.image?.sheet)}
327
+ </article>`
328
+ }).join('')
329
+ return `
330
+ <section id="world" class="page-section">
331
+ ${sectionTitle(4, '场景与道具', '空间连续性、灯光状态与叙事物件')}
332
+ <div class="world-grid">${sceneCards}${propCards}</div>
333
+ </section>`
334
+ }
335
+
336
+ function scriptFlow(project, episode) {
337
+ const characters = new Map(list(project.characters).map((it) => [it.id, it.name]))
338
+ return list(episode.scenes).map((scene, sceneIndex) => {
339
+ const sceneRef = list(project.scenes).find((it) => it.id === scene.sceneId)
340
+ return `<article class="script-scene">
341
+ <header><span>${String(sceneIndex + 1).padStart(2, '0')}</span><h4>${esc(sceneRef?.name || scene.sceneId)}</h4>${badge(scene.lighting || '灯光')}</header>
342
+ <div class="flow">
343
+ ${list(scene.flow).map((item) => item.speaker
344
+ ? `<div class="line"><b>${esc(characters.get(item.speaker) || item.speaker)}</b><p>${esc(item.line)}</p><small>${esc(text(item.delivery, ''))}</small></div>`
345
+ : `<p class="action">${esc(item.action)}</p>`).join('')}
346
+ </div>
347
+ </article>`
348
+ }).join('')
349
+ }
350
+
351
+ function storyboard(episode, media) {
352
+ return list(episode.storyboard?.segments).map((segment) => {
353
+ const seconds = sum(list(segment.cuts), (cut) => cut.seconds)
354
+ const visual = entityMedia(segment, media)
355
+ return `<article class="segment">
356
+ <header>
357
+ <h4>${esc(segment.id)}</h4>
358
+ <span>${esc(`${list(segment.cuts).length} 镜 · ${seconds || '—'} 秒`)}</span>
359
+ </header>
360
+ ${figure(visual, `${segment.id} 分镜素材`)}
361
+ <div class="cut-strip">
362
+ ${list(segment.cuts).map((cut, index) => `<div title="${esc(cut.purpose?.change || cut.frame)}"><b>${String(index + 1).padStart(2, '0')}</b><span>${esc(cut.size || 'shot')}</span><i style="--seconds:${Number(cut.seconds) || 1}"></i></div>`).join('')}
363
+ </div>
364
+ <ol class="cut-list">
365
+ ${list(segment.cuts).map((cut, index) => `<li><span>${String(index + 1).padStart(2, '0')}</span><div><b>${esc([cut.size, cut.camera, `${cut.seconds || '—'}s`].filter(Boolean).join(' / '))}</b><p>${esc(text(cut.purpose?.change || cut.motion?.action || cut.frame))}</p></div></li>`).join('')}
366
+ </ol>
367
+ ${promptBlock('视频提示词', segment.videoPrompt || segment.prompt)}
368
+ </article>`
369
+ }).join('')
370
+ }
371
+
372
+ function episodesSection(project, episodes, media) {
373
+ return `
374
+ <section id="episodes" class="page-section">
375
+ ${sectionTitle(5, '分集剧本与分镜', '对白、动作、镜头节奏与生成计划')}
376
+ <div class="episode-list">
377
+ ${Object.entries(episodes).map(([epNo, episode]) => {
378
+ const seconds = sum(list(episode.storyboard?.segments).flatMap((segment) => list(segment.cuts)), (cut) => cut.seconds)
379
+ return `<article class="episode">
380
+ <header class="episode-head">
381
+ <div><span>E${pad2(epNo)}</span><h3>${esc(list(episode.function).join(' / ') || `第 ${epNo} 集`)}</h3></div>
382
+ <dl><dt>计划时长</dt><dd>${esc(seconds || episode.targetSeconds || '—')} 秒</dd><dt>钩子</dt><dd>${esc(text(episode.hookType))}</dd></dl>
383
+ </header>
384
+ <div class="episode-premise"><b>开场</b><span>${esc(text(episode.hook))}</span><b>落点</b><span>${esc(text(episode.cliff))}</span></div>
385
+ <details class="episode-detail" open>
386
+ <summary>分集剧本</summary>
387
+ <div class="script-grid">${scriptFlow(project, episode)}</div>
388
+ </details>
389
+ <details class="episode-detail" open>
390
+ <summary>分镜表</summary>
391
+ <div class="segment-grid">${storyboard(episode, media)}</div>
392
+ </details>
393
+ </article>`
394
+ }).join('')}
395
+ </div>
396
+ </section>`
397
+ }
398
+
399
+ function navigation(project, episodes) {
400
+ return `<aside class="rail">
401
+ <a class="brand" href="#overview"><b>${esc(project.title)}</b><span>短剧制作档案</span></a>
402
+ <nav>
403
+ <a href="#overview"><span>01</span>制作总览</a>
404
+ <a href="#story"><span>02</span>故事设计</a>
405
+ <a href="#characters"><span>03</span>人物设定 <i>${list(project.characters).length}</i></a>
406
+ <a href="#world"><span>04</span>场景与道具 <i>${list(project.scenes).length + list(project.props).length}</i></a>
407
+ <a href="#episodes"><span>05</span>分集剧本 <i>${Object.keys(episodes).length}</i></a>
408
+ </nav>
409
+ <footer>由 WJI short-drama 项目生成<br>${new Date().toLocaleString('zh-CN', { hour12: false })}</footer>
410
+ </aside>`
411
+ }
412
+
413
+ const CSS = `
414
+ :root{--paper:#f3f4f0;--surface:#ffffff;--ink:#333333;--muted:#6d7064;--rule:#d1d3cc;--rust:#56720a;--sage:#7a9a12;--soft:#e8e9e5;--shadow:rgba(51,51,51,.08)}
415
+ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:"Noto Sans CJK SC","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.65}
416
+ button,input{font:inherit}a{color:inherit;text-decoration:none}.shell{display:grid;grid-template-columns:224px minmax(0,1fr);min-height:100vh}.rail{position:sticky;top:0;height:100vh;padding:30px 22px;border-right:1px solid var(--rule);background:#eaebe5;display:flex;flex-direction:column}.brand{padding-bottom:28px;border-bottom:1px solid var(--ink)}.brand b{display:block;font-family:"Songti SC","STSong",serif;font-size:21px;font-weight:600}.brand span{font-size:11px;color:var(--muted)}.rail nav{display:flex;flex-direction:column;padding-top:18px}.rail nav a{display:grid;grid-template-columns:28px 1fr auto;gap:8px;padding:10px 0;border-bottom:1px solid rgba(0,0,0,.08)}.rail nav span{font-family:Georgia,serif;color:var(--rust);font-size:11px}.rail nav i{font-style:normal;color:var(--muted);font-size:11px}.rail footer{margin-top:auto;color:var(--muted);font-size:10px}.content{min-width:0;padding:0 34px 80px}.masthead{position:sticky;top:0;z-index:20;height:58px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ink);background:rgba(243,244,240,.94);backdrop-filter:blur(8px)}.masthead h1{font-family:"Songti SC","STSong",serif;font-size:22px;font-weight:500;margin:0}.masthead p{margin:0;color:var(--muted);font-size:11px}.page-section{padding:42px 0 18px;scroll-margin-top:58px}.section-head{display:grid;grid-template-columns:34px auto 1fr;align-items:baseline;gap:8px;padding-bottom:10px;margin-bottom:18px;border-bottom:1px solid var(--ink)}.section-head>span{color:var(--rust);font-family:Georgia,serif;font-size:11px}.section-head h2{font-family:"Songti SC","STSong",serif;font-size:19px;font-weight:500;margin:0}.section-head p{justify-self:end;margin:0;color:var(--muted);font-size:11px}.stats{display:grid;grid-template-columns:repeat(8,minmax(85px,1fr));border:1px solid var(--rule);background:var(--surface)}.stats div{padding:14px 13px;border-right:1px solid var(--rule)}.stats div:last-child{border:0}.stats strong{display:block;font-family:"Songti SC","STSong",serif;font-size:21px;font-weight:500}.stats span{display:block;color:var(--muted);font-size:10px}.overview-grid,.story-grid{display:grid;grid-template-columns:1.45fr 1fr;gap:16px;margin-top:16px}.brief,.gate-report,.story-core,.guideline{border:1px solid var(--rule);background:var(--surface);padding:20px}.brief h3,.gate-report h3,.story-core h3,.guideline h3{font-family:"Songti SC","STSong",serif;font-size:17px;margin:0 0 10px}.logline,.story-core>p,.guideline>p{font-family:"Songti SC","STSong",serif;font-size:16px;line-height:1.9;margin:0 0 16px}.brief dl,.story-grid dl,.character-columns dl{margin:0}.field{display:grid;grid-template-columns:82px 1fr;gap:10px;padding:7px 0;border-top:1px solid var(--soft)}.field dt{color:var(--rust);font-size:11px}.field dd{margin:0}.gate{display:grid;grid-template-columns:110px 1fr;gap:8px;padding:8px 0;border-top:1px solid var(--soft)}.gate b{font-size:11px;color:var(--rust)}.gate small{grid-column:2;color:var(--muted)}.all-clear{min-height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;border:1px dashed var(--sage);color:var(--sage)}.all-clear b{font-family:"Songti SC","STSong",serif;font-size:17px}.all-clear span{font-size:11px}.badge{display:inline-flex;align-items:center;padding:1px 7px;border:1px solid var(--rule);border-radius:20px;color:var(--muted);font-size:10px;font-weight:400;white-space:nowrap}.badge.rust{border-color:var(--rust);color:var(--rust)}.badge.quiet{border:0;padding-left:0;color:var(--rust)}.episode-directory{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-top:16px}.episode-directory article{display:grid;grid-template-columns:48px 1fr;gap:12px;padding:16px;border:1px solid var(--rule);background:var(--surface)}.episode-no{font-family:Georgia,serif;font-size:16px;color:var(--rust)}.episode-directory h3{margin:0;font-family:"Songti SC","STSong",serif;font-weight:500}.episode-directory p{color:var(--muted)}.episode-directory footer{display:flex;gap:8px;align-items:flex-start;font-size:11px}.character-list{display:flex;flex-direction:column;gap:18px}.character-card{display:grid;grid-template-columns:minmax(300px,42%) 1fr;border:1px solid var(--rule);background:var(--surface)}.character-visual{border-right:1px solid var(--rule);min-height:420px}.visual{height:100%;margin:0;display:flex;flex-direction:column;background:#fff}.visual img,.visual video{display:block;width:100%;height:100%;min-height:240px;object-fit:contain;background:#1a1b18}.visual figcaption{padding:6px 10px;color:var(--muted);font-size:10px;border-top:1px solid var(--soft)}.visual-empty{min-height:260px;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:24px;text-align:center;background:repeating-linear-gradient(135deg,#f7fce8,#f7fce8 8px,#eef9cc 8px,#eef9cc 16px);color:var(--muted)}.visual-empty[hidden]{display:none}.visual-empty svg{width:64px;height:48px;fill:none;stroke:currentColor;stroke-width:1.5}.visual-empty span{font-family:"Songti SC","STSong",serif}.visual-empty small{max-width:220px;font-size:10px}.character-copy{padding:20px}.character-copy header,.world-card header{display:flex;align-items:center;gap:9px;border-bottom:1px solid var(--rule);padding-bottom:9px}.character-copy header>span,.world-card header>span{color:var(--rust);font-family:Georgia,serif;font-size:11px}.character-copy h3,.world-card h3{font-family:"Songti SC","STSong",serif;font-size:20px;font-weight:500;margin:0}.role{color:var(--rust);font-size:11px}.one-liner{font-family:"Songti SC","STSong",serif;font-size:15px}.character-columns{display:grid;grid-template-columns:1fr 1fr;gap:20px}.character-copy blockquote{margin:14px 0 8px;padding:8px 14px;border-left:2px solid var(--rust);font-family:"Songti SC","STSong",serif}.voice-line{display:flex;gap:8px;color:var(--muted);font-size:11px}.prompt{border-top:1px solid var(--soft);margin-top:8px}.prompt summary{padding:8px 0;cursor:pointer;font-size:11px}.prompt div{padding:10px;background:var(--soft);color:var(--muted);font-size:11px;white-space:pre-wrap}.world-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;align-items:start}.world-card{padding:16px;border:1px solid var(--rule);background:var(--surface)}.world-card>p{min-height:46px}.world-card .visual{height:330px;border:1px solid var(--soft)}.world-card h4{font-family:"Songti SC","STSong",serif;font-weight:500;margin:16px 0 5px}.world-card ol{list-style:none;padding:0;margin:0}.world-card li,.lighting{display:grid;grid-template-columns:130px 1fr;gap:10px;padding:7px 0;border-top:1px solid var(--soft)}.world-card li b,.lighting b{font-size:11px;color:var(--rust)}.world-card li span,.lighting span{font-size:11px;color:var(--muted)}.episode-list{display:flex;flex-direction:column;gap:26px}.episode{border-top:2px solid var(--ink)}.episode-head{display:flex;justify-content:space-between;padding:13px 0}.episode-head>div{display:flex;align-items:center;gap:10px}.episode-head span{font-family:Georgia,serif;color:var(--rust)}.episode-head h3{font-family:"Songti SC","STSong",serif;font-size:18px;font-weight:500;margin:0}.episode-head dl{display:grid;grid-template-columns:auto auto;gap:1px 9px;margin:0;font-size:10px}.episode-head dt{color:var(--muted)}.episode-head dd{margin:0}.episode-premise{display:grid;grid-template-columns:50px 1fr 50px 1fr;gap:10px;padding:12px;background:var(--surface);border:1px solid var(--rule)}.episode-premise b{color:var(--rust);font-size:11px}.episode-detail{margin-top:12px;border:1px solid var(--rule);background:var(--surface)}.episode-detail>summary{padding:10px 14px;cursor:pointer;border-bottom:1px solid var(--rule);font-family:"Songti SC","STSong",serif}.script-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:12px}.script-scene{border:1px solid var(--soft);padding:12px}.script-scene header{display:flex;gap:8px;align-items:center;border-bottom:1px solid var(--rule)}.script-scene header>span{font-family:Georgia,serif;color:var(--rust);font-size:10px}.script-scene h4{font-family:"Songti SC","STSong",serif;font-weight:500;margin:0}.flow{padding-top:8px}.action{padding:7px 9px;margin:5px 0;background:#f7fce8;border-left:2px solid var(--rust);font-family:"Songti SC","STSong",serif}.line{display:grid;grid-template-columns:72px 1fr;gap:4px 10px;padding:7px 0;border-bottom:1px solid var(--soft)}.line b{font-size:11px}.line p{margin:0;font-family:"Songti SC","STSong",serif;font-size:15px}.line small{grid-column:2;color:var(--muted)}.segment-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:12px}.segment{border:1px solid var(--rule);padding:12px}.segment>header{display:flex;justify-content:space-between;border-bottom:1px solid var(--rule)}.segment h4{font-family:Georgia,serif;color:var(--rust);margin:0}.segment>header span{font-size:10px;color:var(--muted)}.cut-strip{height:34px;display:flex;gap:2px;margin:12px 0}.cut-strip div{position:relative;display:flex;flex-direction:column;justify-content:center;min-width:34px;flex:var(--seconds);padding:2px 5px;background:var(--soft);overflow:hidden}.cut-strip div:nth-child(4n+1){border-top:3px solid #56720a}.cut-strip div:nth-child(4n+2){border-top:3px solid #7a9a12}.cut-strip div:nth-child(4n+3){border-top:3px solid #9cb92a}.cut-strip div:nth-child(4n){border-top:3px solid #b5d42e}.cut-strip b{font-size:9px}.cut-strip span{font-size:8px;color:var(--muted)}.cut-strip i{display:none}.cut-list{list-style:none;margin:0;padding:0}.cut-list li{display:grid;grid-template-columns:30px 1fr;gap:8px;padding:8px 0;border-top:1px solid var(--soft)}.cut-list>li>span{font-family:Georgia,serif;color:var(--rust);font-size:10px}.cut-list b{font-size:10px}.cut-list p{margin:2px 0;color:var(--muted);font-size:11px}.segment .visual{height:220px;margin-top:10px}
417
+ @media(max-width:1050px){.shell{grid-template-columns:1fr}.rail{position:static;height:auto;border-right:0;border-bottom:1px solid var(--rule)}.rail nav{display:grid;grid-template-columns:repeat(5,1fr)}.rail nav a{padding-right:12px}.rail footer{display:none}.stats{grid-template-columns:repeat(4,1fr)}.stats div:nth-child(4){border-right:0}.character-card{grid-template-columns:1fr}.character-visual{border-right:0;border-bottom:1px solid var(--rule)}}
418
+ @media(max-width:720px){.content{padding:0 14px 50px}.rail{padding:18px 14px}.rail nav{grid-template-columns:1fr 1fr}.section-head{grid-template-columns:28px 1fr}.section-head p{display:none}.stats{grid-template-columns:repeat(2,1fr)}.stats div:nth-child(even){border-right:0}.overview-grid,.story-grid,.world-grid,.script-grid,.segment-grid,.episode-directory{grid-template-columns:1fr}.character-columns{grid-template-columns:1fr}.character-visual{min-height:280px}.episode-premise{grid-template-columns:48px 1fr}.episode-head{align-items:flex-start}.episode-head dl{display:none}}
419
+ @media print{.rail{display:none}.shell{display:block}.content{padding:0}.masthead{position:static}.page-section{break-before:page}.page-section:first-of-type{break-before:auto}.prompt{display:none}.episode-detail{break-inside:avoid}}
420
+ `
421
+
422
+ export function renderDashboard(opts) {
423
+ const projectDir = resolve(opts.projectDir)
424
+ const outputPath = resolve(opts.outputPath)
425
+ const roots = [opts.assetsDir, join(projectDir, 'assets'), join(projectDir, 'output'), projectDir]
426
+ .filter(Boolean)
427
+ .map((root) => resolve(root))
428
+ const media = {
429
+ files: imageIndex(roots),
430
+ projectDir,
431
+ outputPath,
432
+ }
433
+ const project = opts.project
434
+ const episodes = opts.episodes || {}
435
+ const gateResults = opts.gateResults || []
436
+ return `<!doctype html>
437
+ <html lang="${esc(project.lang || 'zh')}">
438
+ <head>
439
+ <meta charset="utf-8">
440
+ <meta name="viewport" content="width=device-width,initial-scale=1">
441
+ <meta name="color-scheme" content="light">
442
+ <title>${esc(project.title)} · 短剧制作档案</title>
443
+ <style>${CSS}</style>
444
+ </head>
445
+ <body>
446
+ <div class="shell">
447
+ ${navigation(project, episodes)}
448
+ <main class="content">
449
+ <header class="masthead"><h1>${esc(project.title)}</h1><p>${esc(`第 1–${project.params?.episodes || Object.keys(episodes).length} 集 · ${project.params?.genre || '短剧'} · ${project.params?.ratio || '—'}`)}</p></header>
450
+ ${overview(project, episodes, gateResults)}
451
+ ${story(project)}
452
+ ${characters(project, media)}
453
+ ${world(project, media)}
454
+ ${episodesSection(project, episodes, media)}
455
+ </main>
456
+ </div>
457
+ </body>
458
+ </html>`
459
+ }
@@ -29,6 +29,8 @@
29
29
  // prompt <project> audio E01-01 [--full] the segment's Seed Audio soundtrack prompt (timecoded ambience / sfx / music, + lines with --full)
30
30
  // prompt <project> score E01 [--fps 24] the episode's spotting sheet for the post soundtrack (audioTrack post): planned
31
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
32
34
  // manifest <project> [--stage …] [--ep N] unbound WJI objects, in execution order
33
35
  // bind <project> [--ep N] <path> <value> write a WJI id back (path segments may be entity ids)
34
36
  // slug <name>
@@ -36,6 +38,7 @@
36
38
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
37
39
  import { basename, dirname, join, resolve } from 'node:path';
38
40
  import { fileURLToPath } from 'node:url';
41
+ import { renderDashboard } from './short-drama-dashboard.mjs';
39
42
 
40
43
  // ── constants ───────────────────────────────────────────────────────────────
41
44
 
@@ -4972,6 +4975,27 @@ function main(argv) {
4972
4975
  return 0;
4973
4976
  }
4974
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
+
4975
4999
  if (cmd === 'render') {
4976
5000
  const what = pos[1];
4977
5001
  const { project, episodes } = loadProject(input);