@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.
@@ -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('&', '&')
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
+ }