@renoise/video-maker 0.1.2 → 0.2.0

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.
@@ -1,714 +0,0 @@
1
- #!/usr/bin/env npx tsx
2
- /**
3
- * Generate an HTML storyboard preview page from a project.json file.
4
- * Optionally generates reference images for each shot via Gemini.
5
- *
6
- * Usage:
7
- * npx tsx generate-storyboard-html.ts --project-file <project.json> --output <storyboard.html> [--skip-images]
8
- */
9
-
10
- import fs from 'fs/promises'
11
- import path from 'path'
12
- import { getGeminiClient, ensureDir } from '../../../lib/gemini.js'
13
-
14
- interface Character {
15
- id: string
16
- name: string
17
- appearance: string
18
- wardrobe: string
19
- signature_details: string
20
- voice_tone?: string
21
- }
22
-
23
- interface StyleGuide {
24
- visual_style: string
25
- color_palette: string
26
- lighting: string
27
- camera_language: string
28
- negative_prompts: string
29
- }
30
-
31
- interface MusicSection {
32
- start: number
33
- end: number
34
- label: string
35
- }
36
-
37
- interface SuggestedCut {
38
- time: number
39
- end: number
40
- duration: number
41
- section: string
42
- }
43
-
44
- interface MusicAnalysis {
45
- bpm: number
46
- total_duration_s: number
47
- beats: number[]
48
- sections: MusicSection[]
49
- suggested_cuts: SuggestedCut[]
50
- file?: string
51
- }
52
-
53
- interface Shot {
54
- shot_id: string
55
- duration_s: number
56
- music_section: string
57
- beat_sync_notes: string
58
- scene: string
59
- characters: string[]
60
- action: string
61
- camera: string
62
- dialogue: string
63
- continuity_out: string
64
- continuity_in: string | null
65
- prompt: string
66
- reference_image?: string
67
- }
68
-
69
- interface Project {
70
- project: { id: string; title: string; total_duration_s: number; ratio: string }
71
- characters: Character[]
72
- style_guide: StyleGuide
73
- music: MusicAnalysis
74
- shots: Shot[]
75
- }
76
-
77
- function parseArgs(args: string[]): Record<string, string> {
78
- const flags: Record<string, string> = {}
79
- for (let i = 0; i < args.length; i++) {
80
- if (args[i].startsWith('--')) {
81
- const key = args[i].slice(2)
82
- const next = args[i + 1]
83
- if (next && !next.startsWith('--')) {
84
- flags[key] = next
85
- i++
86
- } else {
87
- flags[key] = 'true'
88
- }
89
- }
90
- }
91
- return flags
92
- }
93
-
94
- async function generateReferenceImage(
95
- shot: Shot,
96
- characters: Character[],
97
- styleGuide: StyleGuide,
98
- outputPath: string,
99
- ): Promise<string | null> {
100
- try {
101
- const genAI = getGeminiClient()
102
- const model = genAI.getGenerativeModel({
103
- model: process.env.GEMINI_IMAGE_MODEL ?? 'gemini-3-pro-image-preview',
104
- })
105
-
106
- const charDescriptions = shot.characters
107
- .map((charId) => {
108
- const char = characters.find((c) => c.id === charId)
109
- if (!char) return ''
110
- return `${char.name}: ${char.appearance}. Wearing ${char.wardrobe}. ${char.signature_details}.`
111
- })
112
- .filter(Boolean)
113
- .join('\n')
114
-
115
- const prompt = `Generate a cinematic storyboard reference image.
116
-
117
- Style: ${styleGuide.visual_style}. ${styleGuide.color_palette}. ${styleGuide.lighting}.
118
-
119
- Scene: ${shot.scene}
120
-
121
- Characters:
122
- ${charDescriptions || 'No characters in this shot.'}
123
-
124
- Action: ${shot.action}
125
-
126
- Requirements:
127
- - Photorealistic, like a film still
128
- - ${shot.duration_s <= 8 ? '16:9 landscape' : '16:9 landscape'} aspect ratio
129
- - Show the key moment of the action
130
- - Match the lighting and color palette exactly
131
- - No text, no watermarks, no UI elements
132
-
133
- Avoid: ${styleGuide.negative_prompts}`
134
-
135
- console.log(`[INFO] Generating reference image for ${shot.shot_id}...`)
136
- const result = await model.generateContent(prompt)
137
- const response = result.response
138
- const parts = response.candidates?.[0]?.content?.parts ?? []
139
-
140
- for (const part of parts) {
141
- if (part.inlineData?.mimeType?.startsWith('image/')) {
142
- const imgBytes = Buffer.from(part.inlineData.data, 'base64')
143
- await ensureDir(outputPath)
144
- await fs.writeFile(outputPath, imgBytes)
145
- console.log(`[SUCCESS] Reference image saved: ${outputPath}`)
146
- return part.inlineData.data
147
- }
148
- }
149
-
150
- console.warn(`[WARN] No image generated for ${shot.shot_id}`)
151
- return null
152
- } catch (err) {
153
- console.warn(`[WARN] Failed to generate image for ${shot.shot_id}: ${(err as Error).message}`)
154
- return null
155
- }
156
- }
157
-
158
- function escapeHtml(text: string): string {
159
- return text
160
- .replace(/&/g, '&amp;')
161
- .replace(/</g, '&lt;')
162
- .replace(/>/g, '&gt;')
163
- .replace(/"/g, '&quot;')
164
- }
165
-
166
- function buildTimelineHtml(music: MusicAnalysis, shots: Shot[]): string {
167
- const totalDuration = music.total_duration_s
168
- const sectionColors: Record<string, string> = {
169
- intro: '#6366f1',
170
- buildup: '#6366f1',
171
- verse: '#3b82f6',
172
- verse2: '#3b82f6',
173
- chase: '#3b82f6',
174
- build: '#f59e0b',
175
- confrontation: '#f59e0b',
176
- chorus: '#ef4444',
177
- chorus2: '#ef4444',
178
- clash: '#ef4444',
179
- bridge: '#8b5cf6',
180
- outro: '#6b7280',
181
- aftermath: '#6b7280',
182
- }
183
-
184
- const sectionBars = music.sections
185
- .map((sec) => {
186
- const left = (sec.start / totalDuration) * 100
187
- const width = ((sec.end - sec.start) / totalDuration) * 100
188
- const color = sectionColors[sec.label] ?? '#94a3b8'
189
- return `<div class="tl-section" style="left:${left}%;width:${width}%;background:${color}" title="${sec.label} (${sec.start.toFixed(1)}s - ${sec.end.toFixed(1)}s)">
190
- <span class="tl-label">${sec.label}</span>
191
- </div>`
192
- })
193
- .join('\n')
194
-
195
- const cutMarkers = shots
196
- .map((shot, i) => {
197
- if (i === 0) return ''
198
- const startTime = shots.slice(0, i).reduce((sum, s) => sum + s.duration_s, 0)
199
- const left = (startTime / totalDuration) * 100
200
- return `<div class="tl-cut" style="left:${left}%" title="${shot.shot_id} @ ${startTime.toFixed(1)}s">
201
- <span class="tl-cut-label">${shot.shot_id}</span>
202
- </div>`
203
- })
204
- .join('\n')
205
-
206
- return `
207
- <div class="timeline">
208
- <div class="tl-bar">
209
- ${sectionBars}
210
- ${cutMarkers}
211
- </div>
212
- <div class="tl-times">
213
- <span>0:00</span>
214
- <span>${formatTime(totalDuration / 4)}</span>
215
- <span>${formatTime(totalDuration / 2)}</span>
216
- <span>${formatTime((totalDuration * 3) / 4)}</span>
217
- <span>${formatTime(totalDuration)}</span>
218
- </div>
219
- </div>`
220
- }
221
-
222
- function formatTime(seconds: number): string {
223
- const m = Math.floor(seconds / 60)
224
- const s = Math.floor(seconds % 60)
225
- return `${m}:${s.toString().padStart(2, '0')}`
226
- }
227
-
228
- function buildShotCard(shot: Shot, imageBase64: string | null, index: number, totalShots: number): string {
229
- const imgHtml = imageBase64
230
- ? `<img src="data:image/png;base64,${imageBase64}" alt="${shot.shot_id}" class="card-img" />`
231
- : `<div class="card-img-empty"><svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span>待生成</span></div>`
232
-
233
- const continuityIn = shot.continuity_in
234
- ? `<div class="cont-in"><span class="cont-arrow">&larr;</span> <span class="cont-tag">承接</span> ${escapeHtml(shot.continuity_in)}</div>`
235
- : `<div class="cont-in muted"><span class="cont-tag">起始镜头</span></div>`
236
-
237
- const continuityOut = shot.continuity_out
238
- ? `<div class="cont-out"><span class="cont-tag">传递</span> <span class="cont-arrow">&rarr;</span> ${escapeHtml(shot.continuity_out)}</div>`
239
- : ''
240
-
241
- return `
242
- <div class="shot-card" id="shot-${shot.shot_id}">
243
- <div class="card-header">
244
- <div class="card-badge">${escapeHtml(shot.shot_id)}</div>
245
- <div class="card-meta">
246
- <span class="card-dur">${shot.duration_s}s</span>
247
- <span class="card-sep">/</span>
248
- <span class="card-section">${escapeHtml(shot.music_section)}</span>
249
- </div>
250
- <div class="card-pos">${index + 1} / ${totalShots}</div>
251
- </div>
252
-
253
- <div class="card-body">
254
- <div class="card-visual">${imgHtml}</div>
255
- <div class="card-info">
256
- <div class="info-block">
257
- <div class="info-label">场景</div>
258
- <div class="info-text">${escapeHtml(shot.scene)}</div>
259
- </div>
260
- <div class="info-block">
261
- <div class="info-label">动作</div>
262
- <div class="info-text">${escapeHtml(shot.action)}</div>
263
- </div>
264
- <div class="info-block">
265
- <div class="info-label">运镜</div>
266
- <div class="info-text mono">${escapeHtml(shot.camera)}</div>
267
- </div>
268
- </div>
269
- <div class="card-side">
270
- <div class="info-block">
271
- <div class="info-label">音效 / 对白</div>
272
- <div class="info-text dialogue">${escapeHtml(shot.dialogue || '—')}</div>
273
- </div>
274
- <div class="info-block">
275
- <div class="info-label">节拍卡点</div>
276
- <div class="info-text mono">${escapeHtml(shot.beat_sync_notes || '—')}</div>
277
- </div>
278
- </div>
279
- </div>
280
-
281
- <div class="card-continuity">
282
- ${continuityIn}
283
- ${continuityOut}
284
- </div>
285
-
286
- <details class="card-prompt">
287
- <summary>查看 Seedance Prompt</summary>
288
- <pre class="prompt-code">${escapeHtml(shot.prompt || '尚未生成')}</pre>
289
- </details>
290
- </div>`
291
- }
292
-
293
- function buildHtml(project: Project, imageMap: Map<string, string | null>): string {
294
- const { project: meta, characters, style_guide, music, shots } = project
295
-
296
- const charCards = characters
297
- .map((c) => `
298
- <div class="char-card">
299
- <div class="char-name">${escapeHtml(c.name)}</div>
300
- <div class="char-id">${escapeHtml(c.id)}</div>
301
- <div class="char-desc">${escapeHtml(c.appearance)}</div>
302
- <div class="char-ward">${escapeHtml(c.wardrobe)}</div>
303
- <div class="char-sig">${escapeHtml(c.signature_details)}</div>
304
- </div>`)
305
- .join('\n')
306
-
307
- const timelineHtml = buildTimelineHtml(music, shots)
308
-
309
- const shotCards = shots
310
- .map((shot, i) => buildShotCard(shot, imageMap.get(shot.shot_id) ?? null, i, shots.length))
311
- .join('\n')
312
-
313
- return `<!DOCTYPE html>
314
- <html lang="zh-CN">
315
- <head>
316
- <meta charset="UTF-8" />
317
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
318
- <title>${escapeHtml(meta.title)} — 分镜预览</title>
319
- <style>
320
- :root {
321
- --bg: #ffffff;
322
- --surface: #ffffff;
323
- --surface-2: #f7f7f8;
324
- --surface-3: #f0f0f2;
325
- --border: #e4e4e7;
326
- --border-light: #f0f0f2;
327
- --text-1: #09090b;
328
- --text-2: #3f3f46;
329
- --text-3: #a1a1aa;
330
- --accent: #18181b;
331
- --accent-inv: #ffffff;
332
- --blue: #2563eb;
333
- --blue-light: #eff6ff;
334
- --green: #15803d;
335
- --green-bg: #f0fdf4;
336
- --orange: #c2410c;
337
- --orange-bg: #fff7ed;
338
- --purple: #7c3aed;
339
- --radius: 12px;
340
- --radius-sm: 8px;
341
- --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
342
- --shadow-lg: 0 10px 25px rgba(0,0,0,0.06), 0 4px 10px rgba(0,0,0,0.04);
343
- }
344
-
345
- * { box-sizing: border-box; margin: 0; padding: 0; }
346
-
347
- body {
348
- font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", sans-serif;
349
- background: var(--bg); color: var(--text-1);
350
- line-height: 1.6; -webkit-font-smoothing: antialiased;
351
- }
352
-
353
- .page { max-width: 1080px; margin: 0 auto; padding: 48px 24px 80px; }
354
-
355
- /* ---- Header ---- */
356
- .page-header {
357
- margin-bottom: 40px;
358
- padding-bottom: 32px;
359
- border-bottom: 2px solid var(--accent);
360
- }
361
- .page-title {
362
- font-size: 36px; font-weight: 800; letter-spacing: -0.03em;
363
- color: var(--text-1); line-height: 1.1;
364
- }
365
- .page-subtitle {
366
- font-size: 15px; color: var(--text-3); margin-top: 6px;
367
- font-weight: 500; letter-spacing: 0.02em;
368
- }
369
-
370
- .stats {
371
- display: flex; gap: 6px; margin-top: 24px; flex-wrap: wrap;
372
- }
373
- .stat {
374
- display: inline-flex; align-items: center; gap: 8px;
375
- background: var(--accent); color: var(--accent-inv);
376
- border-radius: 100px; padding: 7px 16px;
377
- font-size: 13px; font-weight: 500;
378
- }
379
- .stat-val { font-weight: 700; font-size: 14px; }
380
-
381
- /* ---- Expandable panels ---- */
382
- .panel {
383
- background: var(--surface);
384
- border: 1px solid var(--border);
385
- border-radius: var(--radius); margin-bottom: 12px;
386
- }
387
- .panel > summary {
388
- padding: 16px 20px; font-size: 14px; font-weight: 700;
389
- color: var(--text-1); cursor: pointer; list-style: none;
390
- display: flex; align-items: center; gap: 10px;
391
- user-select: none; letter-spacing: -0.01em;
392
- }
393
- .panel > summary::-webkit-details-marker { display: none; }
394
- .panel > summary::after {
395
- content: "+"; margin-left: auto;
396
- font-size: 18px; font-weight: 400; color: var(--text-3);
397
- transition: transform 0.15s;
398
- }
399
- .panel[open] > summary::after { content: "\\2212"; }
400
- .panel-body { padding: 0 20px 20px; }
401
-
402
- /* Characters */
403
- .char-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
404
- .char-card {
405
- background: var(--surface-2); border-radius: var(--radius-sm); padding: 16px;
406
- font-size: 13px; line-height: 1.6; border: 1px solid var(--border-light);
407
- }
408
- .char-name { font-weight: 800; font-size: 16px; color: var(--text-1); letter-spacing: -0.01em; }
409
- .char-id { font-size: 11px; color: var(--text-3); font-family: "SF Mono", monospace; margin-bottom: 10px; }
410
- .char-desc { color: var(--text-2); margin-bottom: 6px; }
411
- .char-ward { color: var(--text-2); margin-bottom: 6px; padding-left: 10px; border-left: 2px solid var(--border); }
412
- .char-sig { color: var(--purple); font-size: 12px; font-weight: 600; }
413
-
414
- /* Style guide */
415
- .style-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
416
- .style-item { background: var(--surface-2); border-radius: var(--radius-sm); padding: 14px; border: 1px solid var(--border-light); }
417
- .style-key { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-3); letter-spacing: 0.08em; margin-bottom: 6px; }
418
- .style-val { font-size: 13px; color: var(--text-2); line-height: 1.55; }
419
-
420
- /* ---- Timeline ---- */
421
- .tl-wrap {
422
- background: var(--surface-2);
423
- border-radius: var(--radius); padding: 20px 20px 16px;
424
- margin-bottom: 32px;
425
- }
426
- .tl-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-3); letter-spacing: 0.08em; margin-bottom: 14px; }
427
- .timeline { position: relative; }
428
- .tl-bar {
429
- position: relative; height: 44px; background: var(--surface);
430
- border-radius: var(--radius-sm); overflow: visible;
431
- border: 1px solid var(--border);
432
- }
433
- .tl-section {
434
- position: absolute; top: 0; height: 100%; display: flex;
435
- align-items: center; justify-content: center;
436
- opacity: 0.9; transition: opacity 0.15s;
437
- }
438
- .tl-section:first-child { border-top-left-radius: 7px; border-bottom-left-radius: 7px; }
439
- .tl-section:last-of-type { border-top-right-radius: 7px; border-bottom-right-radius: 7px; }
440
- .tl-section:hover { opacity: 1; }
441
- .tl-label { font-size: 11px; font-weight: 700; color: #fff; text-shadow: 0 1px 4px rgba(0,0,0,0.4); letter-spacing: 0.02em; }
442
- .tl-cut {
443
- position: absolute; top: -8px; width: 0; height: calc(100% + 16px);
444
- border-left: 2px solid var(--text-1); z-index: 2;
445
- }
446
- .tl-cut-label {
447
- position: absolute; top: -22px; left: -12px;
448
- font-size: 10px; font-weight: 700; color: var(--text-1);
449
- background: var(--surface-2); padding: 1px 5px; border-radius: 4px;
450
- }
451
- .tl-times {
452
- display: flex; justify-content: space-between;
453
- font-size: 11px; color: var(--text-3); padding: 8px 2px 0;
454
- font-variant-numeric: tabular-nums; font-weight: 500;
455
- }
456
-
457
- /* ---- Shot Cards ---- */
458
- .shots-header {
459
- display: flex; align-items: baseline; gap: 12px;
460
- margin-bottom: 20px;
461
- }
462
- .shots-title {
463
- font-size: 22px; font-weight: 800; color: var(--text-1);
464
- letter-spacing: -0.02em;
465
- }
466
- .shots-count { font-size: 14px; color: var(--text-3); font-weight: 500; }
467
-
468
- .shot-card {
469
- background: var(--surface);
470
- border: 1px solid var(--border);
471
- border-radius: var(--radius); margin-bottom: 20px;
472
- box-shadow: var(--shadow);
473
- overflow: hidden;
474
- transition: box-shadow 0.2s;
475
- }
476
- .shot-card:hover { box-shadow: var(--shadow-lg); }
477
-
478
- .card-header {
479
- display: flex; align-items: center; gap: 14px;
480
- padding: 14px 20px;
481
- background: var(--surface-2);
482
- border-bottom: 1px solid var(--border);
483
- }
484
- .card-badge {
485
- background: var(--accent); color: var(--accent-inv);
486
- font-size: 14px; font-weight: 800; padding: 3px 14px;
487
- border-radius: 6px; letter-spacing: 0.02em;
488
- }
489
- .card-meta { font-size: 14px; color: var(--text-2); display: flex; align-items: center; gap: 8px; }
490
- .card-dur { font-weight: 800; color: var(--text-1); font-size: 15px; }
491
- .card-sep { color: var(--border); }
492
- .card-section { color: var(--text-3); font-weight: 500; }
493
- .card-pos { margin-left: auto; font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; font-weight: 600; }
494
-
495
- .card-body {
496
- display: grid; grid-template-columns: 220px 1fr 280px;
497
- gap: 0;
498
- }
499
-
500
- .card-visual {
501
- padding: 20px;
502
- display: flex; align-items: flex-start; justify-content: center;
503
- border-right: 1px solid var(--border-light);
504
- background: var(--surface-2);
505
- }
506
- .card-img {
507
- width: 100%; height: auto; border-radius: var(--radius-sm);
508
- box-shadow: var(--shadow);
509
- }
510
- .card-img-empty {
511
- width: 180px; height: 102px; background: var(--surface);
512
- border: 2px dashed var(--border); border-radius: var(--radius-sm);
513
- display: flex; flex-direction: column; align-items: center; justify-content: center;
514
- gap: 6px; color: var(--text-3); font-size: 12px; font-weight: 500;
515
- }
516
- .card-img-empty svg { opacity: 0.3; }
517
-
518
- .card-info {
519
- padding: 20px; border-right: 1px solid var(--border-light);
520
- display: flex; flex-direction: column; gap: 14px;
521
- }
522
-
523
- .card-side {
524
- padding: 20px;
525
- display: flex; flex-direction: column; gap: 14px;
526
- }
527
-
528
- .info-label {
529
- font-size: 10px; font-weight: 700; text-transform: uppercase;
530
- letter-spacing: 0.08em; color: var(--text-3); margin-bottom: 4px;
531
- }
532
- .info-text { font-size: 13px; color: var(--text-2); line-height: 1.6; }
533
- .info-text.mono {
534
- font-family: "SF Mono", "Fira Code", monospace; font-size: 12px;
535
- background: var(--surface-2); padding: 8px 10px; border-radius: 6px;
536
- line-height: 1.7; border: 1px solid var(--border-light);
537
- }
538
- .info-text.dialogue { color: var(--purple); font-weight: 500; }
539
-
540
- /* Continuity bar */
541
- .card-continuity {
542
- display: grid; grid-template-columns: 1fr 1fr; gap: 0;
543
- border-top: 1px solid var(--border);
544
- font-size: 12px; line-height: 1.55;
545
- }
546
- .cont-in, .cont-out { padding: 12px 20px; color: var(--text-2); }
547
- .cont-out { border-left: 1px solid var(--border); }
548
- .cont-tag {
549
- display: inline-block; font-size: 10px; font-weight: 800;
550
- text-transform: uppercase; letter-spacing: 0.06em;
551
- padding: 2px 8px; border-radius: 4px; margin-bottom: 4px;
552
- }
553
- .cont-in .cont-tag { background: var(--green-bg); color: var(--green); }
554
- .cont-out .cont-tag { background: var(--orange-bg); color: var(--orange); }
555
- .cont-arrow { font-weight: 700; }
556
- .cont-in.muted { color: var(--text-3); }
557
- .cont-in.muted .cont-tag { background: var(--surface-2); color: var(--text-3); }
558
-
559
- /* Prompt expandable */
560
- .card-prompt { border-top: 1px solid var(--border); }
561
- .card-prompt > summary {
562
- padding: 12px 20px; font-size: 13px; font-weight: 700;
563
- color: var(--blue); cursor: pointer; list-style: none;
564
- display: flex; align-items: center; gap: 8px; user-select: none;
565
- transition: background 0.1s;
566
- }
567
- .card-prompt > summary::-webkit-details-marker { display: none; }
568
- .card-prompt > summary::before { content: "\\25B6"; font-size: 9px; transition: transform 0.15s; }
569
- .card-prompt[open] > summary::before { transform: rotate(90deg); }
570
- .card-prompt > summary:hover { background: var(--blue-light); }
571
- .prompt-code {
572
- margin: 0 20px 16px; padding: 16px;
573
- background: var(--surface-2); border: 1px solid var(--border);
574
- border-radius: var(--radius-sm); font-size: 12px; line-height: 1.7;
575
- font-family: "SF Mono", "Fira Code", monospace;
576
- white-space: pre-wrap; word-break: break-word;
577
- max-height: 360px; overflow-y: auto; color: var(--text-2);
578
- }
579
-
580
- /* ---- Footer ---- */
581
- .page-footer {
582
- text-align: center; padding: 40px 0 0;
583
- font-size: 12px; color: var(--text-3); font-weight: 500;
584
- }
585
-
586
- /* ---- Responsive ---- */
587
- @media (max-width: 900px) {
588
- .card-body { grid-template-columns: 1fr; }
589
- .card-visual { border-right: none; border-bottom: 1px solid var(--border-light); }
590
- .card-info { border-right: none; border-bottom: 1px solid var(--border-light); }
591
- .card-img-empty, .card-img { width: 200px; }
592
- .card-continuity { grid-template-columns: 1fr; }
593
- .cont-out { border-left: none; border-top: 1px solid var(--border); }
594
- }
595
- @media (max-width: 480px) {
596
- .page { padding: 20px 14px 40px; }
597
- .page-title { font-size: 26px; }
598
- .stats { gap: 4px; }
599
- .stat { padding: 5px 12px; font-size: 12px; }
600
- }
601
- </style>
602
- </head>
603
- <body>
604
-
605
- <div class="page">
606
- <header class="page-header">
607
- <h1 class="page-title">${escapeHtml(meta.title)}</h1>
608
- <p class="page-subtitle">分镜预览</p>
609
- <div class="stats">
610
- <div class="stat">总时长 <span class="stat-val">${formatTime(meta.total_duration_s)}</span></div>
611
- <div class="stat">片段 <span class="stat-val">${shots.length}</span></div>
612
- ${music.bpm ? `<div class="stat">BPM <span class="stat-val">${music.bpm}</span></div>` : ''}
613
- <div class="stat">比例 <span class="stat-val">${escapeHtml(meta.ratio)}</span></div>
614
- </div>
615
- </header>
616
-
617
- <details class="panel" open>
618
- <summary>角色设定</summary>
619
- <div class="panel-body">
620
- <div class="char-grid">${charCards || '<p style="color:var(--text-3)">暂无角色定义</p>'}</div>
621
- </div>
622
- </details>
623
-
624
- <details class="panel">
625
- <summary>风格指南</summary>
626
- <div class="panel-body">
627
- <div class="style-grid">
628
- <div class="style-item"><div class="style-key">视觉风格</div><div class="style-val">${escapeHtml(style_guide.visual_style)}</div></div>
629
- <div class="style-item"><div class="style-key">色彩方案</div><div class="style-val">${escapeHtml(style_guide.color_palette)}</div></div>
630
- <div class="style-item"><div class="style-key">灯光</div><div class="style-val">${escapeHtml(style_guide.lighting)}</div></div>
631
- <div class="style-item"><div class="style-key">镜头语言</div><div class="style-val">${escapeHtml(style_guide.camera_language)}</div></div>
632
- <div class="style-item"><div class="style-key">禁止项</div><div class="style-val">${escapeHtml(style_guide.negative_prompts)}</div></div>
633
- </div>
634
- </div>
635
- </details>
636
-
637
- <div class="tl-wrap">
638
- <div class="tl-title">时间线</div>
639
- ${timelineHtml}
640
- </div>
641
-
642
- <div class="shots-header">
643
- <div class="shots-title">分镜详情</div>
644
- <div class="shots-count">${shots.length} 个片段 / ${meta.total_duration_s}s</div>
645
- </div>
646
- ${shotCards}
647
-
648
- <footer class="page-footer">
649
- short-film-editor
650
- </footer>
651
- </div>
652
-
653
- </body>
654
- </html>`
655
- }
656
-
657
- async function main() {
658
- const flags = parseArgs(process.argv.slice(2))
659
- const projectFile = flags['project-file']
660
- const outputPath = flags['output']
661
- const skipImages = flags['skip-images'] === 'true'
662
-
663
- if (!projectFile || !outputPath) {
664
- console.error('Usage: generate-storyboard-html.ts --project-file <project.json> --output <storyboard.html> [--skip-images]')
665
- process.exit(1)
666
- }
667
-
668
- const raw = await fs.readFile(projectFile, 'utf-8')
669
- const project: Project = JSON.parse(raw)
670
-
671
- // Generate reference images for each shot
672
- const imageMap = new Map<string, string | null>()
673
- const storyboardDir = path.join(path.dirname(projectFile), 'storyboard')
674
-
675
- for (const shot of project.shots) {
676
- if (skipImages) {
677
- // Check if image already exists on disk
678
- const imgPath = path.join(storyboardDir, `${shot.shot_id}.png`)
679
- try {
680
- const imgData = await fs.readFile(imgPath)
681
- imageMap.set(shot.shot_id, imgData.toString('base64'))
682
- console.log(`[INFO] Using existing image for ${shot.shot_id}`)
683
- } catch {
684
- imageMap.set(shot.shot_id, null)
685
- }
686
- } else if (shot.reference_image) {
687
- // Use pre-existing base64
688
- imageMap.set(shot.shot_id, shot.reference_image)
689
- } else {
690
- // Generate via Gemini
691
- const imgPath = path.join(storyboardDir, `${shot.shot_id}.png`)
692
- const base64 = await generateReferenceImage(
693
- shot,
694
- project.characters,
695
- project.style_guide,
696
- imgPath,
697
- )
698
- imageMap.set(shot.shot_id, base64)
699
- }
700
- }
701
-
702
- // Build and write HTML
703
- const html = buildHtml(project, imageMap)
704
- await ensureDir(outputPath)
705
- await fs.writeFile(outputPath, html, 'utf-8')
706
-
707
- const sizeKb = (await fs.stat(outputPath)).size / 1024
708
- console.log(`[SUCCESS] Storyboard saved to ${outputPath} (${sizeKb.toFixed(0)} KB)`)
709
- }
710
-
711
- main().catch((err) => {
712
- console.error('[ERROR]', err.message)
713
- process.exit(1)
714
- })