@renoise/video-maker 0.1.3 → 0.2.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/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +21 -3
- package/README.md +25 -30
- package/hooks/check-api-key.sh +28 -0
- package/hooks/hooks.json +12 -0
- package/hooks/session-start.sh +30 -7
- package/openclaw.plugin.json +5 -3
- package/package.json +4 -9
- package/skills/director/SKILL.md +4 -7
- package/skills/file-upload/SKILL.md +79 -0
- package/skills/file-upload/scripts/upload.mjs +103 -0
- package/skills/gemini-gen/SKILL.md +236 -0
- package/skills/gemini-gen/scripts/gemini.mjs +220 -0
- package/skills/renoise-gen/SKILL.md +3 -1
- package/skills/renoise-gen/references/api-endpoints.md +37 -33
- package/skills/short-film-editor/SKILL.md +23 -24
- package/skills/short-film-editor/references/continuity-guide.md +2 -2
- package/skills/tiktok-content-maker/SKILL.md +78 -81
- package/skills/tiktok-content-maker/examples/dress-demo.md +42 -42
- package/skills/tiktok-content-maker/references/ecom-prompt-guide.md +157 -152
- package/skills/video-download/SKILL.md +1 -1
- package/lib/gemini.ts +0 -49
- package/skills/short-film-editor/scripts/generate-storyboard-html.ts +0 -714
- package/skills/tiktok-content-maker/scripts/analyze-images.ts +0 -122
|
@@ -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, '&')
|
|
161
|
-
.replace(/</g, '<')
|
|
162
|
-
.replace(/>/g, '>')
|
|
163
|
-
.replace(/"/g, '"')
|
|
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">←</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">→</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
|
-
})
|