feat(video-pipeline): 用 ffprobe 实际测量音视频时长并统一字段名为 script

将项目中的 `narration` 字段统一重命名为 `script`,并新增 `getAudioDurationSec` 函数通过 `ffprobe` 实际测量音频和视频文件的时长,替代 Manifest 中的估计值,提高时间线组装的准确性。同时优化字幕逻辑,仅在有 TTS 音频时调整视频速度。
This commit is contained in:
2026-05-01 01:52:02 +08:00
parent 7d526d2b60
commit c878abc39b
8 changed files with 130 additions and 41 deletions

View File

@@ -123,6 +123,19 @@ async function batchUploadToOSS(inputDir, files) {
return urls return urls
} }
function getAudioDurationSec(filePath) {
return new Promise((resolve) => {
execFile('ffprobe', [
'-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'csv=p=0', filePath
], (err, stdout) => {
if (err) { resolve(null); return }
const dur = parseFloat(stdout.trim())
resolve(dur > 0 ? dur : null)
})
})
}
// ============================================================================ // ============================================================================
// 主流程 // 主流程
// ============================================================================ // ============================================================================
@@ -131,7 +144,7 @@ function buildTimeline(items, defaultDurationUs) {
// 音频为主轴视频调速适配≤2x 加速,>2x 截断) // 音频为主轴视频调速适配≤2x 加速,>2x 截断)
let offset = 0 let offset = 0
return items.map(item => { return items.map(item => {
const audioDur = (item.audioDuration != null) ? item.audioDuration * US : (item.duration != null) ? item.duration * US : 0 const audioDur = (item.audioDuration != null) ? item.audioDuration * US : 0
const videoDur = (item.videoDuration != null) ? item.videoDuration * US : 0 const videoDur = (item.videoDuration != null) ? item.videoDuration * US : 0
// 无 TTS用视频时长或固定时长 // 无 TTS用视频时长或固定时长
if (audioDur <= 0) { if (audioDur <= 0) {
@@ -193,10 +206,37 @@ async function assemble(args) {
if (items.length === 0) throw new Error('没有可用的素材文件') if (items.length === 0) throw new Error('没有可用的素材文件')
// 统一时间线:由 duration 驱动TTS 音频时长)或 fallback 到固定时长 // 用 ffprobe 测量实际音频/视频时长,替代 manifest 中的估计值
let audioMeasured = 0, videoMeasured = 0
for (const item of items) {
// 测量 TTS 音频实际时长
if (item.audio && !item.audio.startsWith('http')) {
const audioPath = path.isAbsolute(item.audio)
? item.audio
: path.resolve(inputDir, item.audio)
if (fs.existsSync(audioPath)) {
const actualDur = await getAudioDurationSec(audioPath)
if (actualDur != null) { item.audioDuration = actualDur; audioMeasured++ }
}
}
// 测量视频文件实际时长(生成器返回的是硬编码常量,不准确)
if (item.video) {
const videoPath = path.isAbsolute(item.video)
? item.video
: path.resolve(inputDir, item.video)
if (fs.existsSync(videoPath)) {
const actualDur = await getAudioDurationSec(videoPath) // ffprobe 对音视频通用
if (actualDur != null) { item.videoDuration = actualDur; videoMeasured++ }
}
}
}
if (audioMeasured > 0 || videoMeasured > 0) {
console.log(` 实际时长测量: 音频 ${audioMeasured} 个, 视频 ${videoMeasured}`)
}
const timeline = buildTimeline(items, defaultDurationUs) const timeline = buildTimeline(items, defaultDurationUs)
const totalDurationUs = timeline.length > 0 ? timeline[timeline.length - 1].end : 0 const totalDurationUs = timeline.length > 0 ? timeline[timeline.length - 1].end : 0
const hasTTS = items.some(item => item.audio && (item.audioDuration != null || item.duration != null)) const hasTTS = items.some(item => item.audio && item.audioDuration != null)
console.log(`\nCapCut 成片组装`) console.log(`\nCapCut 成片组装`)
console.log(` 模式: ${mode} 画幅: ${format} (${width}x${height})`) console.log(` 模式: ${mode} 画幅: ${format} (${width}x${height})`)
@@ -280,7 +320,7 @@ async function assemble(args) {
if (manifestFile) { if (manifestFile) {
try { try {
const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')) const m = JSON.parse(fs.readFileSync(manifestFile, 'utf-8'))
const mi = m.items.find(i => i.id === item.id || i.narration === (item.narration || item.text) || i.text === (item.narration || item.text)) const mi = m.items.find(i => i.id === item.id || i.script === item.script || i.text === item.text)
if (mi) { mi.videoUrl = url; fs.writeFileSync(manifestFile, JSON.stringify(m, null, 2)) } if (mi) { mi.videoUrl = url; fs.writeFileSync(manifestFile, JSON.stringify(m, null, 2)) }
} catch (_) {} } catch (_) {}
} }
@@ -316,7 +356,7 @@ async function assemble(args) {
// -- 添加字幕 -- // -- 添加字幕 --
step++; console.log(`[${step}/${totalSteps}] 添加字幕...`) step++; console.log(`[${step}/${totalSteps}] 添加字幕...`)
if (subtitles === 'true' && items.some(i => i.narration || i.text)) { if (subtitles === 'true' && items.some(i => i.script || i.text)) {
await addSubtitles(draftUrl, items, timeline, subtitleStyle) await addSubtitles(draftUrl, items, timeline, subtitleStyle)
} else { } else {
console.log(' 跳过') console.log(' 跳过')
@@ -669,7 +709,7 @@ async function addSubtitles(draftUrl, items, timeline, style = {}) {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i] const item = items[i]
const text = item.narration || item.text || item.caption || '' const text = item.script || item.text || item.caption || ''
if (!text) continue if (!text) continue
const tl = timeline[i] const tl = timeline[i]

View File

@@ -40,7 +40,7 @@ function initManifest(options) {
} }
// 校验必填字段 // 校验必填字段
const requiredFields = ['shotDesc', 'narration', 'imagePrompt'] const requiredFields = ['shotDesc', 'script', 'imagePrompt']
const resolvedMode = mode || 'single' const resolvedMode = mode || 'single'
for (let i = 0; i < rawItems.length; i++) { for (let i = 0; i < rawItems.length; i++) {
@@ -72,7 +72,7 @@ function initManifest(options) {
id: i + 1, id: i + 1,
status: 'pending', status: 'pending',
shotDesc: raw.shotDesc || '', shotDesc: raw.shotDesc || '',
narration: raw.narration || raw.text || '', script: raw.script || '',
duration: raw.duration || 5, duration: raw.duration || 5,
imagePrompt: raw.imagePrompt, imagePrompt: raw.imagePrompt,
confirmed: false, confirmed: false,

View File

@@ -32,7 +32,7 @@ function validateManifest(manifestPath) {
if (manifest.items && Array.isArray(manifest.items)) { if (manifest.items && Array.isArray(manifest.items)) {
manifest.items.forEach((item, i) => { manifest.items.forEach((item, i) => {
const prefix = `items[${i}]` const prefix = `items[${i}]`
if (!item.narration && !item.text) issues.push(`${prefix} 缺少 narration 或 text中文旁白`) if (!item.script && !item.text) issues.push(`${prefix} 缺少 script 或 text中文文案`)
if (!item.shotDesc) issues.push(`${prefix} 缺少 shotDesc分镜描述`) if (!item.shotDesc) issues.push(`${prefix} 缺少 shotDesc分镜描述`)
if (!item.imagePrompt) issues.push(`${prefix} 缺少 imagePrompt`) if (!item.imagePrompt) issues.push(`${prefix} 缺少 imagePrompt`)
if (manifest.mode === 'framePair' && !item.lastFramePrompt) { if (manifest.mode === 'framePair' && !item.lastFramePrompt) {

View File

@@ -110,7 +110,7 @@ async function generateGemini(item, idx, dir, imagesDir, ratio, refs) {
const file = (result.savedFiles && result.savedFiles.length > 0) const file = (result.savedFiles && result.savedFiles.length > 0)
? renameGeneratedFile( ? renameGeneratedFile(
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'), path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
dir, idx, item.narration || item.shotDesc, '' dir, idx, item.script || item.shotDesc, ''
) )
: null : null
return { file } return { file }
@@ -184,7 +184,7 @@ async function generateKling(item, idx, dir, imagesDir, ratio, refs) {
const file = (result.savedFiles && result.savedFiles.length > 0) const file = (result.savedFiles && result.savedFiles.length > 0)
? renameGeneratedFile( ? renameGeneratedFile(
path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'), path.relative(dir, result.savedFiles[0]).replace(/\\/g, '/'),
dir, idx, item.narration || item.shotDesc, '' dir, idx, item.script || item.shotDesc, ''
) )
: null : null
return { file } return { file }

View File

@@ -15,7 +15,7 @@ async function phaseTts(manifest, manifestPath, options = {}) {
const { synthesize } = require('../qwen-tts') const { synthesize } = require('../qwen-tts')
const items = manifest.items.filter(it => const items = manifest.items.filter(it =>
it.status === 'done' && (it.narration || it.text) && !it.audio it.status === 'done' && (it.script || it.text) && !it.audio
) )
if (items.length === 0) { log('tts', '无待处理 item跳过'); return } if (items.length === 0) { log('tts', '无待处理 item跳过'); return }
@@ -25,7 +25,7 @@ async function phaseTts(manifest, manifestPath, options = {}) {
const item = items[i] const item = items[i]
const idx = i + 1 const idx = i + 1
try { try {
const { filePath, duration } = await synthesize(item.narration || item.text, { const { filePath, duration } = await synthesize(item.script || item.text, {
outputDir: audioDir, outputDir: audioDir,
id: item.id || idx, id: item.id || idx,
voice: manifest.ttsVoice || undefined, voice: manifest.ttsVoice || undefined,
@@ -33,7 +33,7 @@ async function phaseTts(manifest, manifestPath, options = {}) {
}) })
item.audio = path.relative(dir, filePath).replace(/\\/g, '/') item.audio = path.relative(dir, filePath).replace(/\\/g, '/')
item.audioDuration = Math.round(duration * 1000) / 1000 item.audioDuration = Math.round(duration * 1000) / 1000
log('tts', `[${idx}/${items.length}] ${duration.toFixed(1)}s: ${(item.narration || item.text).substring(0, 30)}...`) log('tts', `[${idx}/${items.length}] ${duration.toFixed(1)}s: ${(item.script || item.text).substring(0, 30)}...`)
} catch (err) { } catch (err) {
item.status = 'failed' item.status = 'failed'
item.error = `TTS失败: ${err.message}` item.error = `TTS失败: ${err.message}`

View File

@@ -62,9 +62,27 @@ source outside the frame begins its slow rotation
→ 有明确运动趋势:头正在转向、影子正在拉长——视频模型能推断运动方向。 → 有明确运动趋势:头正在转向、影子正在拉长——视频模型能推断运动方向。
## 三、切割规则 ## 三、3秒钩子规则Shot 1 强制)
### 3.1 切割优先级 短视频前 3 秒决定用户是否留下。**Shot 1 必须是钩子,不是铺垫。**
| 策略 | 说明 |
|------|------|
| **结论前置** | 从文案核心金句提取最冲击的结论,直接放在开头 |
| **认知冲突** | 一句话打破常识,制造"凭什么"的好奇心 |
| **身份挑衅** | 直接点中受众身份焦虑 |
钩子规范:
- 画面有视觉冲击力,不用背影/空走廊等铺垫
- 文案 ≤ 20 字,一句话讲完
- 时长 4-5 秒,短狠快
- 禁止设问式开头("大多数人...")、禁止超 20 字、禁止纯铺垫画面
钩子后 Shot 2 负责引入正文Shot 3 起按原文顺序展开。
## 四、切割规则
### 4.1 切割优先级
以「语义场景单元」为第一切割依据,不按句号机械切割。 以「语义场景单元」为第一切割依据,不按句号机械切割。
@@ -77,20 +95,20 @@ source outside the frame begins its slow rotation
| 语义完整 | 该段表达一个完整观点或例子 | | 语义完整 | 该段表达一个完整观点或例子 |
| 字数上限 | 视频成片每段 22 字左右;图文成片每段 50 字左右 | | 字数上限 | 视频成片每段 22 字左右;图文成片每段 50 字左右 |
### 3.2 时长控制 ### 4.2 时长控制
- **图文成片:** 每条 Shot 4-10 秒,跟随旁白节奏,完整表达一个观点 - **图文成片:** 每条 Shot 4-10 秒,跟随旁白节奏,完整表达一个观点
- **视频成片:** 每条 Shot 3-7 秒,目标 5 秒,匹配视频片段长度 - **视频成片:** 每条 Shot 3-7 秒,目标 5 秒,匹配视频片段长度
- **总时长校验:** 所有 duration 之和 = 文案朗读总时长 - **总时长校验:** 所有 duration 之和 = 文案朗读总时长
## 、导演构图语言词库(分镜层专用) ## 、导演构图语言词库(分镜层专用)
> 本层只负责:构图逻辑 + 画面内容设计 + 视角选择 > 本层只负责:构图逻辑 + 画面内容设计 + 视角选择
> 光影渲染由图片提示词处理,运动节奏由视频提示词处理 > 光影渲染由图片提示词处理,运动节奏由视频提示词处理
每个 Shot 选定一位导演作为构图参考,写入 `directorRef` 字段向下游透传。下游图片和视频提示词根据此字段执行各自层的风格,不重新选导演。 每个 Shot 选定一位导演作为构图参考,写入 `directorRef` 字段向下游透传。下游图片和视频提示词根据此字段执行各自层的风格,不重新选导演。
### 4.1 昆汀·塔伦蒂诺Tarantino ### 5.1 昆汀·塔伦蒂诺Tarantino
**构图核心:** 身体局部主导叙事;对话即权力博弈;平静表面下的极度张力 **构图核心:** 身体局部主导叙事;对话即权力博弈;平静表面下的极度张力
@@ -114,7 +132,7 @@ room has not yet realized is coming
**适合选用场景:** 微行为解码 / 潜台词型文案 / 局部细节承载叙事 **适合选用场景:** 微行为解码 / 潜台词型文案 / 局部细节承载叙事
### 4.2 北野武Kitano ### 5.2 北野武Kitano
**构图核心:** 静止即叙事;留白承载重量;人物与空间的关系即情绪 **构图核心:** 静止即叙事;留白承载重量;人物与空间的关系即情绪
@@ -139,7 +157,7 @@ His body has not moved. Neither has his decision.
**适合选用场景:** 孤独/等待/沉默型文案 / 收尾 Shot / 留白叙事 **适合选用场景:** 孤独/等待/沉默型文案 / 收尾 Shot / 留白叙事
### 4.3 大卫·芬奇Fincher ### 5.3 大卫·芬奇Fincher
**构图核心:** 精确的控制感;对称中的破坏;冷静凝视是最深的压迫 **构图核心:** 精确的控制感;对称中的破坏;冷静凝视是最深的压迫
@@ -164,13 +182,13 @@ The balance of power broke the same moment the geometry did.
**适合选用场景:** 规律揭示型文案 / 解剖者视角 / 关系结构拆解 **适合选用场景:** 规律揭示型文案 / 解剖者视角 / 关系结构拆解
## 、shotDesc 写法规范 ## 、shotDesc 写法规范
### 5.1 语言 ### 6.1 语言
统一英文输出。shotDesc 是下游图片模型的内容底稿,英文输入更稳定。视频提示词的语言由下游模块根据目标模型自动适配。 统一英文输出。shotDesc 是下游图片模型的内容底稿,英文输入更稳定。视频提示词的语言由下游模块根据目标模型自动适配。
### 5.2 必须包含的内容维度 ### 6.2 必须包含的内容维度
**图文成片模式:** **图文成片模式:**
@@ -192,7 +210,7 @@ The balance of power broke the same moment the geometry did.
| 隐性动势 | 画面中隐含的运动趋势(**必填** | | 隐性动势 | 画面中隐含的运动趋势(**必填** |
| 情绪张力 | 用视觉词而非情绪词传递张力 | | 情绪张力 | 用视觉词而非情绪词传递张力 |
### 5.3 隐性动势Implied Motion ### 6.3 隐性动势Implied Motion
**视频成片模式:每条 shotDesc 必须包含至少一个隐性动势词组。** **视频成片模式:每条 shotDesc 必须包含至少一个隐性动势词组。**
**图文成片模式:不强制,可选用以增加画面叙事感。** **图文成片模式:不强制,可选用以增加画面叙事感。**
@@ -220,7 +238,7 @@ the symmetry of the empty table stretching to both edges
a man holding a cup and looking down a man holding a cup and looking down
``` ```
### 5.4 隐性动势词库 ### 6.4 隐性动势词库
**人物动势:** **人物动势:**
@@ -248,12 +266,12 @@ breaks / silence stretching thin / the moment before something that cannot be
undone undone
``` ```
### 5.5 字数控制 ### 6.5 字数控制
- **图文成片:** 每条 shotDesc **5080 词**——图片即成品,需要充分描述构图、氛围和视觉隐喻 - **图文成片:** 每条 shotDesc **5080 词**——图片即成品,需要充分描述构图、氛围和视觉隐喻
- **视频成片:** 每条 shotDesc **3060 词**——视频模型需要精炼聚焦的运动指令,过长会稀释动势信号 - **视频成片:** 每条 shotDesc **3060 词**——视频模型需要精炼聚焦的运动指令,过长会稀释动势信号
### 5.6 禁止事项 ### 6.6 禁止事项
- 禁止写镜头运动参数(`zoom-in` / `pan`)——留给视频提示词 - 禁止写镜头运动参数(`zoom-in` / `pan`)——留给视频提示词
- 禁止写色调参数(`cold blue` / `warm orange`)——留给图片提示词 - 禁止写色调参数(`cold blue` / `warm orange`)——留给图片提示词
@@ -261,7 +279,7 @@ undone
- **视频成片:** 禁止纯静止描述,必须附加至少一个隐性动势词 - **视频成片:** 禁止纯静止描述,必须附加至少一个隐性动势词
- **图文成片:** 禁止连续两张同景别/同构图的 shot - **图文成片:** 禁止连续两张同景别/同构图的 shot
## 、directorRef 选择规则 ## 、directorRef 选择规则
| 选 Tarantino | 选 Kitano | 选 Fincher | | 选 Tarantino | 选 Kitano | 选 Fincher |
|-------------|-----------|-----------| |-------------|-----------|-----------|
@@ -273,7 +291,7 @@ undone
- **视频成片**优先 Tarantino微行为动势强、Fincher细节暗示运动 - **视频成片**优先 Tarantino微行为动势强、Fincher细节暗示运动
- **图文成片**优先 Kitano留白冲击力强、Fincher构图控制精确 - **图文成片**优先 Kitano留白冲击力强、Fincher构图控制精确
## 、输入规范 ## 、输入规范
``` ```
【完整口播文案】 【完整口播文案】
@@ -283,7 +301,7 @@ undone
图文成片 / 视频成片 图文成片 / 视频成片
``` ```
## 、输出格式 ## 、输出格式
输出前附加总览行: 输出前附加总览行:
@@ -298,7 +316,7 @@ undone
{ {
"id": 1, "id": 1,
"shotDesc": "英文画面描述图文50-80词 / 视频30-60词", "shotDesc": "英文画面描述图文50-80词 / 视频30-60词",
"narration": "该段完整原文案,不提炼,保留论证、例子、细节", "script": "该段完整原文逐字摘取,一字不改,禁止改写/提炼/摘要",
"duration": 5, "duration": 5,
"directorRef": "tarantino / kitano / fincher" "directorRef": "tarantino / kitano / fincher"
} }

View File

@@ -14,6 +14,7 @@
] ]
} }
}, },
"ttsVoice": "cosyvoice-v3.5-plus-bailian-fa8787c0f70b4ba2a907c35511e6a6f6",
"ttsInstruction": "用沉稳有力的男性声音朗读,语速适中偏慢,语气低沉、坚定、有压迫感,像是一个看透人性的老手在冷静地讲述残酷的真相", "ttsInstruction": "用沉稳有力的男性声音朗读,语速适中偏慢,语气低沉、坚定、有压迫感,像是一个看透人性的老手在冷静地讲述残酷的真相",
"storyboardPrompt": "prompts/分镜.md", "storyboardPrompt": "prompts/分镜.md",
"imageStylePrompt": "prompts/图片提示词.md", "imageStylePrompt": "prompts/图片提示词.md",

View File

@@ -154,7 +154,37 @@ geometry. The balance of power broke the same moment
the geometry did. the geometry did.
``` ```
## 六、切割规则 ## 六、3秒钩子规则Shot 1 强制)
短视频前 3 秒决定用户是否留下。**Shot 1 必须是钩子,不是铺垫。**
### 钩子策略
| 策略 | 说明 | 示例 |
|------|------|------|
| **结论前置** | 从原文结尾或核心金句中提取最具冲击力的结论,直接放在开头 | "你混得不好,不是因为你太善良。" |
| **认知冲突** | 一句话打破用户常识,制造"凭什么这么说"的好奇心 | "这个世界不奖励好人,也不惩罚坏人。" |
| **身份挑衅** | 直接点中目标受众的身份焦虑 | "你把80%的认知带宽,花在了管理别人对你的评价上。" |
### 钩子 shotDesc 规范
- **画面必须有视觉冲击力**:不用背影、走廊等铺垫画面;用裂开的盾牌、燃烧的铁器、破碎的对称等"破坏感"画面
- **构图禁止大面积留白**:留白是铺垫用的,钩子要"满"或"炸"
- **文案 ≤ 20 字**:一句话讲完,不留悬念尾巴
- **时长 4-5 秒**:钩子要短、狠、快
### 禁止的钩子写法
- "大多数人..."、"你有没有想过..." — 设问式开头太慢
- 纯铺垫画面(空走廊、远背影)— 3 秒内没有视觉锚点
- 超过 20 字的钩子文案 — 用户来不及看完就划走了
### 钩子之后的 Shot 2
钩子说完冲击性结论后Shot 2 负责"收回来"引入正文:
> Shot 1钩子"你混得不好,不是因为太善良。" → Shot 2引入"为什么?让我拆给你看。" → Shot 3 起按原文顺序展开
## 七、切割规则
### 6.1 切割优先级 ### 6.1 切割优先级
@@ -313,7 +343,7 @@ something that cannot be undone
{ {
"id": 1, "id": 1,
"shotDesc": "英文画面描述图文50-80词 / 视频30-60词", "shotDesc": "英文画面描述图文50-80词 / 视频30-60词",
"narration": "该段的完整原文案,不提炼,保留论证、例子、细节", "script": "该段的完整原文案,不提炼,保留论证、例子、细节",
"duration": 5, "duration": 5,
"directorRef": "tarantino / kitano / fincher" "directorRef": "tarantino / kitano / fincher"
} }
@@ -321,7 +351,7 @@ something that cannot be undone
``` ```
**字段说明** **字段说明**
- `narration`:该段的**完整原文**包含论证、例子、细节,不是金句 - `script`:该段的**完整原文逐字摘取,一字不改**。原文怎么写就怎么贴,禁止改写、提炼、摘要、概括、换词。包含论证、例子、细节,不是金句
- 按语义单元切割,确保每段表达一个完整观点或例子 - 按语义单元切割,确保每段表达一个完整观点或例子
## 十一、完整示例 ## 十一、完整示例
@@ -350,21 +380,21 @@ something that cannot be undone
{ {
"id": 1, "id": 1,
"shotDesc": "a solitary figure in a dark traditional robe seated at the far end of a dim wooden hall, three-quarters of the frame filled with empty floor and gathering shadow — the man occupies only the leftmost edge of the composition, back straight, shoulders set with the stillness of someone who has already decided. The space around him continues to darken.", "shotDesc": "a solitary figure in a dark traditional robe seated at the far end of a dim wooden hall, three-quarters of the frame filled with empty floor and gathering shadow — the man occupies only the leftmost edge of the composition, back straight, shoulders set with the stillness of someone who has already decided. The space around him continues to darken.",
"narration": "权力从来不大声说话。它藏在一个人坐在哪里,看向哪里,在哪句话之后沉默了三秒。", "script": "权力从来不大声说话。它藏在一个人坐在哪里,看向哪里,在哪句话之后沉默了三秒。",
"duration": 6, "duration": 6,
"directorRef": "kitano" "directorRef": "kitano"
}, },
{ {
"id": 2, "id": 2,
"shotDesc": "extreme close-up of a man's eyes, half-lowered, tracking slowly across the room with the precision of someone reading a document no one else can see — his gaze moves but his head does not. In the blurred background, the edge of another figure waits, unknowingly being measured and filed away.", "shotDesc": "extreme close-up of a man's eyes, half-lowered, tracking slowly across the room with the precision of someone reading a document no one else can see — his gaze moves but his head does not. In the blurred background, the edge of another figure waits, unknowingly being measured and filed away.",
"narration": "权力藏在一个人坐在哪里,看向哪里。你看到的是他的位置,他看到的是整个房间的结构。", "script": "权力藏在一个人坐在哪里,看向哪里。你看到的是他的位置,他看到的是整个房间的结构。",
"duration": 6, "duration": 6,
"directorRef": "tarantino" "directorRef": "tarantino"
}, },
{ {
"id": 3, "id": 3,
"shotDesc": "a near-symmetrical frame — two hands visible on a low table, one pair relaxed and open, one pair with fingers slowly pressing flat, knuckles beginning to whiten. The geometric precision of the table edge divides the frame exactly in half. The whitening knuckles are the only thing breaking the symmetry — and the silence.", "shotDesc": "a near-symmetrical frame — two hands visible on a low table, one pair relaxed and open, one pair with fingers slowly pressing flat, knuckles beginning to whiten. The geometric precision of the table edge divides the frame exactly in half. The whitening knuckles are the only thing breaking the symmetry — and the silence.",
"narration": "在哪句话之后沉默了三秒。这种沉默不是等待,是审视。今天,我们逐帧拆解权力运行的底层逻辑。", "script": "在哪句话之后沉默了三秒。这种沉默不是等待,是审视。今天,我们逐帧拆解权力运行的底层逻辑。",
"duration": 6, "duration": 6,
"directorRef": "fincher" "directorRef": "fincher"
} }
@@ -395,21 +425,21 @@ something that cannot be undone
{ {
"id": 1, "id": 1,
"shotDesc": "a figure seated at the far end of a dim wooden hall, three-quarters of the frame filled with empty floor — the man's body is perfectly still but his head is beginning a slow almost imperceptible turn toward the door at the right edge of frame, as if he has heard something the camera has not yet revealed. The shadow on the floor continues to lengthen.", "shotDesc": "a figure seated at the far end of a dim wooden hall, three-quarters of the frame filled with empty floor — the man's body is perfectly still but his head is beginning a slow almost imperceptible turn toward the door at the right edge of frame, as if he has heard something the camera has not yet revealed. The shadow on the floor continues to lengthen.",
"narration": "权力从来不大声说话。它藏在一个人坐在哪里,看向哪里,在哪句话之后沉默了三秒。", "script": "权力从来不大声说话。它藏在一个人坐在哪里,看向哪里,在哪句话之后沉默了三秒。",
"duration": 5, "duration": 5,
"directorRef": "kitano" "directorRef": "kitano"
}, },
{ {
"id": 2, "id": 2,
"shotDesc": "extreme close-up of a man's eyes, half-lowered, beginning to track slowly to the left with the precision of someone reading a document no one else can see — his gaze shifts but his head does not move yet, and in the blurred background a second figure's shoulder is beginning to come into focus.", "shotDesc": "extreme close-up of a man's eyes, half-lowered, beginning to track slowly to the left with the precision of someone reading a document no one else can see — his gaze shifts but his head does not move yet, and in the blurred background a second figure's shoulder is beginning to come into focus.",
"narration": "权力藏在一个人坐在哪里,看向哪里。你看到的是他的位置,他看到的是整个房间的结构。", "script": "权力藏在一个人坐在哪里,看向哪里。你看到的是他的位置,他看到的是整个房间的结构。",
"duration": 5, "duration": 5,
"directorRef": "tarantino" "directorRef": "tarantino"
}, },
{ {
"id": 3, "id": 3,
"shotDesc": "a near-symmetrical frame — two pairs of hands on a low table, one pair relaxed, the other with fingers slowly pressing flat and knuckles beginning to whiten. The table edge divides the frame exactly in half. The whitening knuckles are the only motion in the frame, pressing harder, as the geometric order begins its quiet collapse.", "shotDesc": "a near-symmetrical frame — two pairs of hands on a low table, one pair relaxed, the other with fingers slowly pressing flat and knuckles beginning to whiten. The table edge divides the frame exactly in half. The whitening knuckles are the only motion in the frame, pressing harder, as the geometric order begins its quiet collapse.",
"narration": "在哪句话之后沉默了三秒。这种沉默不是等待,是审视。今天,我们逐帧拆解权力运行的底层逻辑。", "script": "在哪句话之后沉默了三秒。这种沉默不是等待,是审视。今天,我们逐帧拆解权力运行的底层逻辑。",
"duration": 5, "duration": 5,
"directorRef": "fincher" "directorRef": "fincher"
} }