diff --git a/.env.example b/.env.example index d4935b2..433d04b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,12 @@ # 火山方舟 — 复制为 .env,勿提交 .env ARK_API_KEY= ARK_ENDPOINT= + +# 可选:OpenAI-compatible ASR +ASR_BASE_URL= +ASR_MODEL= +ASR_TRANSCRIBE_PATH=/audio/transcriptions +ASR_API_KEY= + +# 可选:Pexels stock footage +PEXELS_API_KEY= diff --git a/.gitignore b/.gitignore index 1b5d1ce..6558fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ out/ uploads/ +apps/api/data/ .env .env.local *.log diff --git a/README.md b/README.md index aad5524..976332a 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ | 代码仓库 | 可运行的完整项目 | | 演示视频 | 展示核心流程与亮点 | | 视频产物 case | 样例迁移前后的对比案例 | -| 项目说明文档 | 含整体 AI 架构、工具协议、安全边界 | +| 项目说明文档 | [`项目说明文档.md`](项目说明文档.md),含整体 AI 架构、工具协议、安全边界 | 详细设计与实现计划见 `[docs/DESIGN.md](docs/DESIGN.md)`。 @@ -346,15 +346,50 @@ | -------- | ---------------------------------- | | 模型 | Doubao-Seed-2.0-lite | | Endpoint | 见团队内部配置 | -| API Key | **勿写入仓库**;使用环境变量,参考 `.env.example` | +| API Key | **勿写入仓库**;使用环境变量,参考 `apps/api/.env.example` | ```bash -# .env(本地,勿提交) +# apps/api/.env(本地,勿提交) ARK_API_KEY=your_key_here ARK_ENDPOINT=your_endpoint_here ``` +可选运行时配置: + +```bash +# ASR:OpenAI-compatible audio transcription endpoint;不配置则跳过转写 +ASR_BASE_URL= +ASR_MODEL= +ASR_TRANSCRIBE_PATH=/audio/transcriptions +ASR_API_KEY= + +# 缺口补全:Pexels stock video;不配置则回退 text_card / reused_clip +PEXELS_API_KEY= +``` + +## 本地运行与验证 + +```bash +# API +cd apps/api +npm install +npm test +npm run typecheck +npm run gen:schema +npm run regression:case +npm start + +# Web +cd apps/web +npm install +npm run build +npm run dev +``` + +默认 API 端口 `3001`,Web 端口 `5173`,Web 会把 `/api` 代理到 `http://127.0.0.1:3001`。 +`npm run regression:case` 会生成固定 smoke case 的 MP4 和 `apps/api/out/regression/report.json`,用于检查迁移、渲染、QC 和音频质量。 + --- ## 项目文档 @@ -366,6 +401,4 @@ ARK_ENDPOINT=your_endpoint_here | `[docs/knowledge/00-项目总览.md](docs/knowledge/00-项目总览.md)` | Obsidian-style 活知识库 / project tracker:模块进展、概念解释、架构与决策记录 | | `[docs/PROJECT_TRACKER.md](docs/PROJECT_TRACKER.md)` | 旧 tracker 入口,已指向 `docs/knowledge/` | | `[docs/DESIGN.md](docs/DESIGN.md)` | 架构、数据模型、实现阶段与里程碑 | -| `docs/ARCHITECTURE.md` | (待写)AI 架构、工具协议、安全边界(交付用) | - - +| [`项目说明文档.md`](项目说明文档.md) | 项目交付说明:AI 架构、工具协议、安全边界、AI 工具使用与自主实现边界 | diff --git a/apps/api/.env.example b/apps/api/.env.example index f2a0213..9c38867 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -12,3 +12,13 @@ ARK_ENDPOINT= # 样例 / 用户素材上传大小上限(MB),默认 200;需与 Fastify multipart 一致 # MAX_UPLOAD_MB=500 + +# 可选:OpenAI-compatible ASR 转写端点。未配置时 analyze / asr endpoint 会优雅跳过转写。 +# ASR_BASE_URL=https://example.com/v1 +# ASR_MODEL=whisper-1 +# ASR_TRANSCRIBE_PATH=/audio/transcriptions +# ASR_API_KEY= + +# 可选:Pexels 免费视频检索(用真实素材补齐缺口)。无 key 时缺口回退 text_card 占位。 +# 注册免费 key:https://www.pexels.com/api/ +PEXELS_API_KEY= diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json index eaa427d..1c76f73 100644 --- a/apps/api/package-lock.json +++ b/apps/api/package-lock.json @@ -9,19 +9,57 @@ "version": "0.0.0", "dependencies": { "@fastify/multipart": "^8.3.1", + "@remotion/bundler": "^4.0.467", + "@remotion/renderer": "^4.0.467", "fastify": "^4.28.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "remotion": "^4.0.467", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.2" }, "devDependencies": { "@types/form-data": "^2.2.1", "@types/node": "^20.14.0", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "form-data": "^4.0.5", "tsx": "^4.16.2", "typescript": "^5.5.4", "vitest": "^2.0.5" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -29,7 +67,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -46,7 +83,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -63,7 +99,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -80,7 +115,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -97,7 +131,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -114,7 +147,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -131,7 +163,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -148,7 +179,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -165,7 +195,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -182,7 +211,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -199,7 +227,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -216,7 +243,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -233,7 +259,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -250,7 +275,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -267,7 +291,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -284,7 +307,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -301,7 +323,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -318,7 +339,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -335,7 +355,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -352,7 +371,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -369,7 +387,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -386,7 +403,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -403,7 +419,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -420,7 +435,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -437,7 +451,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -454,7 +467,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -551,19 +563,439 @@ ], "license": "MIT" }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.45.0.tgz", + "integrity": "sha512-vLQw8cY7Me6pvTTMkMhOiH9UCuINzfTOETCeDxbGNeNfDqc/7QlxloUH1Ylp/Zz2ek0O8kc6YdygV2vWAPakrA==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.45.0.tgz", + "integrity": "sha512-LfKbAMZVkxRS7PpEIVnWOY/l0KcHv+rjO7pYY3O0TPCZvbHWfrnQjn8JPacPIfuq6Yv7r4f8lhcl7yHSynoRkQ==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.45.0.tgz", + "integrity": "sha512-Bobi6AaQYEc7TWmPJ8Q0/hcUtBN7pLUC2qjoC7oZR4FcGqGztby6k7A1SWlmswoMOEIhYsOrgDaemrSDAC0QVQ==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@remotion/bundler": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.467.tgz", + "integrity": "sha512-RiGEO0DrobpUE1sI1qVQHh6JKw2EBhGshyXvXyh3r7LPmzLoRsTag5fYl/4WfZgStnpQxXAMnFqFalu9nuR5hg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.467", + "@remotion/studio": "4.0.467", + "@remotion/studio-shared": "4.0.467", + "@remotion/timeline-utils": "4.0.467", + "@rspack/core": "1.7.6", + "@rspack/plugin-react-refresh": "1.6.1", + "css-loader": "7.1.4", + "esbuild": "0.28.0", + "react-refresh": "0.18.0", + "remotion": "4.0.467", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.467.tgz", + "integrity": "sha512-xrdRvvKvgV3CAFL6yzAbyLE5tq6Gq+8fYKbZi7Iv1mtOrS97iOCtQFqs67EprjzZUxcGPPRfHQgy9Lr2aLJwcg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.467.tgz", + "integrity": "sha512-2WV88ZvcELhBArBpL6vzPtGrmg/r9zqHGeDkgAFi4Yx/28CSKloscjxFP4d2wd5lxIUWGlZSILMJddg3CEloEQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.467.tgz", + "integrity": "sha512-hLChjs7bHRWoC+Cz0eQPSYBcEckLFlJJKK+ltHozHlGLMxvveILB+5tD22KvGDwM/87scyiumJh7klhPsaWR/g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.467.tgz", + "integrity": "sha512-6l1aCl+yiSlWQrlkjtpaqEfMbzWUtI69sidJjqtmr6/jGQt9maP8IUW3Lc9Mu7noPLAKnrv7lfKKEbAWCLlKBQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.467.tgz", + "integrity": "sha512-WgiZMEH2x7LFNO29AVYHAufEpFozmS2FLLUrLDTJpYuxX567+T6voe+FFyQ5OCrlAD+ebN5rOxQwfW31ozCwlg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.467.tgz", + "integrity": "sha512-52DYHeqeBJdQsqwl8+DrsAGPBcATHZhWQK6JYo+uWxGJrIUFUWKTXsg8adW44nCiF5eKBtDkkW1h3L0PLHP6fQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.467.tgz", + "integrity": "sha512-4W29jG7wC1tFCD1lD79MyREY/HP/baf3+UdNgW47TvzTqbSRVyd0OeDw+BWi2Bo+FM9nbxb0smRKyVanGJm0Ag==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/licensing": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.467.tgz", + "integrity": "sha512-nAYTshbA1A7HBs7FBDnqx+Xvi69Riv0Mgtz13/wFIOX9drLpeWUnRNQLiZqcg2nsms2SIyF48M4Bvzr2Tvg0Dw==", + "license": "MIT" + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.467.tgz", + "integrity": "sha512-K/g5OcWIWAkW22ZqPW/89B3xwNJ1Byhr4B/bathcorRe5LNimHf7OQ1upA2nBcZ4AUJ+a9jvhXt8gsOkGH2E6A==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.467.tgz", + "integrity": "sha512-tTC7iACnqm+fby6tg9O8emOjvdpM/lnWWWsG4F5rOVW4d9FRoO8tXKCq7rW3nHo5JWF4VOI7SntdaHuAIONJog==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.45.0", + "remotion": "4.0.467" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.467.tgz", + "integrity": "sha512-xmJq+8g7cyJOY/TRzjDPXhw584YGDtUZ+hEiCqRiUb7A8GoMWMBb1FQWNZQ77kv63ZPkfdNBDe5F+C1SqyEW1w==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.467" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.467.tgz", + "integrity": "sha512-0QRtHAqnb2Oqe/MgqtNw5GSPv5KB0Q37ep2nEyP5kos6LkTn+KrSRp/wWWIYnF+E0N58oZUfKoBd9fMB/kyxrQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.467", + "@remotion/streaming": "4.0.467", + "execa": "5.1.1", + "remotion": "4.0.467", + "source-map": "0.8.0-beta.0", + "ws": "8.20.1" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.467", + "@remotion/compositor-darwin-x64": "4.0.467", + "@remotion/compositor-linux-arm64-gnu": "4.0.467", + "@remotion/compositor-linux-arm64-musl": "4.0.467", + "@remotion/compositor-linux-x64-gnu": "4.0.467", + "@remotion/compositor-linux-x64-musl": "4.0.467", + "@remotion/compositor-win32-x64-msvc": "4.0.467" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.467.tgz", + "integrity": "sha512-NZcQ0K9w1jrOozOmmQjdS7TRCFGdYynnCgvef/0p40LfwWNmgku+JiYM9R98ZryRxjO4eHGNfZ+QF7krjHmd/A==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.467.tgz", + "integrity": "sha512-GhgY6Hz2JLERaPtsI0oj/Nk0IGxGE8L1mDgt7DOSsCuKEqjnGDk9+ZVJ0IRng5VevnF7f3FyAjIIoSs3/m78VQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@remotion/media-utils": "4.0.467", + "@remotion/player": "4.0.467", + "@remotion/renderer": "4.0.467", + "@remotion/studio-shared": "4.0.467", + "@remotion/timeline-utils": "4.0.467", + "@remotion/web-renderer": "4.0.467", + "@remotion/zod-types": "4.0.467", + "mediabunny": "1.45.0", + "memfs": "3.4.3", + "open": "8.4.2", + "remotion": "4.0.467", + "semver": "7.5.3", + "zod": "4.3.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.467.tgz", + "integrity": "sha512-bTyOZaubmUKLmr8kW8e8+EL4Ph7rAO6qT1jqj8adVKZDaBV2EBc3QIwgY0YTcrMsYj4W5bDVtCbTJZSSV8CMXg==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.467" + } + }, + "node_modules/@remotion/studio/node_modules/@remotion/zod-types": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.467.tgz", + "integrity": "sha512-5+VQObxPHdNOWv0VuznCHFDHEL/OHVM1yUoP7V6lgXBYG8Jdhgcd9RRlu/HClL5hvAkRLCUCyBLfyoKCwKJ6+A==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.467" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@remotion/studio/node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@remotion/studio/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@remotion/timeline-utils": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.467.tgz", + "integrity": "sha512-ZWRjAWWGfp8+MUEHLaAuLUqMOroNFMSnMA1JiR1o5OL54ENkannhapc2UOD13TC/pqq4LPv/p4fEzvuFzYwdww==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.45.0" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.467.tgz", + "integrity": "sha512-sZa6TTRi5doXXveDw0Lk3U2M/kd1SD7eCAp3x7f93SIvgVm8gziq9NebVvoCaSUrOCfTv+MXx1QsXEzFraguGw==", + "license": "UNLICENSED", + "dependencies": { + "@mediabunny/aac-encoder": "1.45.0", + "@mediabunny/flac-encoder": "1.45.0", + "@mediabunny/mp3-encoder": "1.45.0", + "@remotion/licensing": "4.0.467", + "mediabunny": "1.45.0", + "remotion": "4.0.467" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -914,32 +1346,296 @@ "win32" ] }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/form-data": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-2.2.1.tgz", - "integrity": "sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ==", - "dev": true, + "node_modules/@rspack/binding": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.6.tgz", + "integrity": "sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ==", "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.6", + "@rspack/binding-darwin-x64": "1.7.6", + "@rspack/binding-linux-arm64-gnu": "1.7.6", + "@rspack/binding-linux-arm64-musl": "1.7.6", + "@rspack/binding-linux-x64-gnu": "1.7.6", + "@rspack/binding-linux-x64-musl": "1.7.6", + "@rspack/binding-wasm32-wasi": "1.7.6", + "@rspack/binding-win32-arm64-msvc": "1.7.6", + "@rspack/binding-win32-ia32-msvc": "1.7.6", + "@rspack/binding-win32-x64-msvc": "1.7.6" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.6.tgz", + "integrity": "sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.6.tgz", + "integrity": "sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.6.tgz", + "integrity": "sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.6.tgz", + "integrity": "sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.6.tgz", + "integrity": "sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.6.tgz", + "integrity": "sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.6.tgz", + "integrity": "sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.6.tgz", + "integrity": "sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.6.tgz", + "integrity": "sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.6.tgz", + "integrity": "sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.6.tgz", + "integrity": "sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.6", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", + "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/form-data": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-2.2.1.tgz", + "integrity": "sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } }, "node_modules/@vitest/expect": { "version": "2.1.9", @@ -1054,12 +1750,194 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1093,6 +1971,18 @@ } } }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/ajv/node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -1145,6 +2035,57 @@ "fastq": "^1.17.1" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1169,6 +2110,26 @@ "node": ">= 0.4" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1196,6 +2157,15 @@ "node": ">= 16" } }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1203,21 +2173,95 @@ "dev": true, "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, "engines": { - "node": ">= 0.6" + "node": ">=4" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1246,6 +2290,15 @@ "node": ">=6" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1271,6 +2324,34 @@ "node": ">= 0.4" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1331,7 +2412,6 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1369,6 +2449,58 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1379,6 +2511,38 @@ "@types/estree": "^1.0.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1543,6 +2707,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1607,6 +2777,24 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1620,6 +2808,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1662,6 +2865,43 @@ "node": ">= 0.4" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1671,6 +2911,71 @@ "node": ">= 0.10" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-ref-resolver": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", @@ -1697,6 +3002,25 @@ "set-cookie-parser": "^2.4.1" } }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1704,6 +3028,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1724,11 +3060,46 @@ "node": ">= 0.4" } }, + "node_modules/mediabunny": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.45.0.tgz", + "integrity": "sha512-oK3sMMYbucoF6LUX62L/2M9d+p9ve6KDQgL87kNfhsB0/XmTe9iRLUcgQgg9Gpgvi8Sb96zYfOUL6i17y0bdNg==", + "license": "MPL-2.0", + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1738,7 +3109,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -1747,6 +3117,15 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1758,7 +3137,6 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -1773,6 +3151,33 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1782,6 +3187,47 @@ "node": ">=14.0.0" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1803,7 +3249,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/pino": { @@ -1863,7 +3308,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1880,14 +3324,92 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=4" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", @@ -1907,12 +3429,51 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -1922,6 +3483,16 @@ "node": ">= 12.13.0" } }, + "node_modules/remotion": { + "version": "4.0.467", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.467.tgz", + "integrity": "sha512-fKnnpjOycp2s5kxY2mi0a9LOtfXt1+9J96bm8XAoZxVpAVQTO+1YZYLiQrJL4Rq56EOmxTlUkFaJuLKjqpXzLQ==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2026,6 +3597,31 @@ "node": ">=10" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -2050,6 +3646,27 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2057,6 +3674,12 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -2066,11 +3689,42 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2092,6 +3746,12 @@ "dev": true, "license": "MIT" }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -2108,6 +3768,137 @@ "node": ">=4.0.0" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -2170,6 +3961,22 @@ "node": ">=20" } }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.22.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", @@ -2207,7 +4014,42 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/vite": { @@ -2789,6 +4631,114 @@ } } }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2806,6 +4756,33 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/apps/api/package.json b/apps/api/package.json index b7f5c7b..e38019b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,19 +11,28 @@ "start": "tsx src/server/index.ts", "gen:schema": "tsx src/core/scripts/emit-json-schema.ts", "render:demo": "tsx src/render/scripts/render-demo.ts", + "render:demo:remotion": "tsx src/render/scripts/render-demo-remotion.ts", + "regression:case": "tsx src/scripts/regression-runner.ts", "analyze:demo": "tsx src/media/scripts/analyze-demo.ts", "check:ark": "tsx src/scripts/check-ark.ts", "structure:demo": "tsx src/agents/scripts/structure-demo.ts" }, "dependencies": { "@fastify/multipart": "^8.3.1", + "@remotion/bundler": "^4.0.467", + "@remotion/renderer": "^4.0.467", "fastify": "^4.28.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "remotion": "^4.0.467", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.2" }, "devDependencies": { "@types/form-data": "^2.2.1", "@types/node": "^20.14.0", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "form-data": "^4.0.5", "tsx": "^4.16.2", "typescript": "^5.5.4", diff --git a/apps/api/seeds/global-learned-patterns.seed.json b/apps/api/seeds/global-learned-patterns.seed.json new file mode 100644 index 0000000..24e6e29 --- /dev/null +++ b/apps/api/seeds/global-learned-patterns.seed.json @@ -0,0 +1,26379 @@ +[ + { + "id": "pattern_acde4f75", + "scope": "global", + "sourceSampleId": "27fa77dd-301d-4533-9ddf-d0bd3be87263", + "name": "1FC9E6E0-1756-415A-8CAC-28ED6106FDCA · 展示模式", + "summary": "该样例是 8s 的 展示 视频,结构为 高颜值家居场景直出 -> 空间布局细节传递 -> 场景全貌定格留印象,节奏为 慢节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "low", + "hook", + "develop", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "not_recommended", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": false, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:高颜值家居场景直出 -> 场景全貌定格留印象", + "formula": "高颜值家居场景直出 -> 空间布局细节传递 -> 场景全貌定格留印象", + "source": { + "filename": "1FC9E6E0-1756-415A-8CAC-28ED6106FDCA.MOV", + "durationSec": 7.638, + "aspectRatio": "1280:720", + "shotCount": 1 + }, + "segments": [ + { + "role": "hook", + "label": "高颜值家居场景直出", + "durationRatio": 0.3, + "intent": "第一时间抓取家装兴趣类用户的注意力,避免划走", + "copyPattern": "无台词纯视觉冲击开场,直接呈现完整场景全貌", + "watchingPurpose": "开场抓停:第一时间抓取家装兴趣类用户的注意力,避免划走" + }, + { + "role": "develop", + "label": "空间布局细节传递", + "durationRatio": 0.5, + "intent": "完整输出入户区域的功能分区、硬装材质搭配信息", + "copyPattern": "固定视角长镜头全景式展示,无多余运镜干扰信息接收", + "watchingPurpose": "推进主体:完整输出入户区域的功能分区、硬装材质搭配信息" + }, + { + "role": "closing", + "label": "场景全貌定格留印象", + "durationRatio": 0.2, + "intent": "让观众完整接收所有空间信息后形成清晰记忆点", + "copyPattern": "画面稳定停留在完整空间构图,强化整体风格感知", + "watchingPurpose": "收束记忆点:让观众完整接收所有空间信息后形成清晰记忆点" + } + ], + "pacing": { + "durationSec": 7.638, + "shotCount": 1, + "avgShotSec": 7.64, + "cutDensity": "low", + "peakAt": 0.5, + "beatHints": [ + "轻缓家居向纯音单节拍铺垫" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "顶部窄条极简家装场景主题标题", + "stickerUsage": "无多余装饰贴纸,仅保留平台原生标识", + "transitionStyle": "无转场单镜头连贯呈现", + "coverStyle": "低视角入户空间全景居中展示,突出硬装统一色调" + }, + "editingTechniques": [ + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "顶部窄条极简家装场景主题标题", + "stickerUsage": "无多余装饰贴纸,仅保留平台原生标识", + "coverStyle": "低视角入户空间全景居中展示,突出硬装统一色调", + "overlayStyle": "顶部窄条极简家装场景主题标题 / 无多余装饰贴纸,仅保留平台原生标识", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 1 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 7.638, + "bpm": 92, + "beatCount": 12, + "beatStability": 0.45, + "onsetDensity": 0.13092432573972243, + "energyShape": "mid_peak", + "peakAt": 0.5, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217 + ], + "phraseBoundariesSec": [ + 0, + 5.217 + ], + "sections": [ + { + "startSec": 0, + "endSec": 1.375, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 1.375, + "endSec": 4.201, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609 + ] + }, + { + "startSec": 4.201, + "endSec": 5.576, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 5.217 + ] + }, + { + "startSec": 5.576, + "endSec": 7.638, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:mid_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_27fa77dd-301d-4533-9ddf-d0bd3be87263", + "source": "global_sample", + "durationSec": 7.638, + "music": { + "hasAudio": true, + "durationSec": 7.638, + "bpm": 92, + "beatCount": 12, + "beatStability": 0.45, + "onsetDensity": 0.13092432573972243, + "energyShape": "mid_peak", + "peakAt": 0.5, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217 + ], + "phraseBoundariesSec": [ + 0, + 5.217 + ], + "sections": [ + { + "startSec": 0, + "endSec": 1.375, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 1.375, + "endSec": 4.201, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609 + ] + }, + { + "startSec": 4.201, + "endSec": 5.576, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 5.217 + ] + }, + { + "startSec": 5.576, + "endSec": 7.638, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:mid_peak" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 1, + "avgShotSec": 7.64, + "peakAt": 0.5, + "cutEveryBeats": 11.712, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "高颜值家居场景直出" + }, + { + "eventType": "caption", + "timeSec": 2.291, + "relativeTime": 0.2999476302697041, + "beatIndex": 4, + "phraseIndex": 0, + "nearestBeatSec": 2.609, + "offsetMs": -318, + "segmentRole": "develop", + "strength": "medium", + "description": "空间布局细节传递" + }, + { + "eventType": "caption", + "timeSec": 6.11, + "relativeTime": 0.7999476302697042, + "beatIndex": 9, + "phraseIndex": 1, + "nearestBeatSec": 5.87, + "offsetMs": 240, + "segmentRole": "closing", + "strength": "medium", + "description": "场景全貌定格留印象" + } + ], + "cutIntervalsSec": [ + 7.638 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:1 镜,平均 7.6s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_27fa77dd-301d-4533-9ddf-d0bd3be87263", + "source": "global_sample", + "durationSec": 7.638, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": false, + "hasViewportSlides": false, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 1 个,硬切 0 个。", + "真实音频 onset 2 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 4, + "relativeTime": 0.524, + "strength": "strong", + "energyDb": -6.982 + }, + { + "timeSec": 6, + "relativeTime": 0.786, + "strength": "weak", + "energyDb": -11.366 + } + ], + "events": [ + { + "kind": "internal_motion", + "timeSec": 2.533, + "relativeTime": 0.332, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 4, + "relativeTime": 0.524, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 6, + "relativeTime": 0.786, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "ken_burns_in", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "高颜值家居场景直出 -> 空间布局细节传递 -> 场景全貌定格留印象", + "segmentCount": 3, + "segments": [ + { + "role": "hook", + "label": "高颜值家居场景直出", + "durationRatio": 0.3, + "intent": "第一时间抓取家装兴趣类用户的注意力,避免划走", + "copyPattern": "无台词纯视觉冲击开场,直接呈现完整场景全貌", + "watchingPurpose": "开场抓停:第一时间抓取家装兴趣类用户的注意力,避免划走" + }, + { + "role": "develop", + "label": "空间布局细节传递", + "durationRatio": 0.5, + "intent": "完整输出入户区域的功能分区、硬装材质搭配信息", + "copyPattern": "固定视角长镜头全景式展示,无多余运镜干扰信息接收", + "watchingPurpose": "推进主体:完整输出入户区域的功能分区、硬装材质搭配信息" + }, + { + "role": "closing", + "label": "场景全貌定格留印象", + "durationRatio": 0.2, + "intent": "让观众完整接收所有空间信息后形成清晰记忆点", + "copyPattern": "画面稳定停留在完整空间构图,强化整体风格感知", + "watchingPurpose": "收束记忆点:让观众完整接收所有空间信息后形成清晰记忆点" + } + ], + "notes": [ + "脚本结构由 3 个段落组成,按 hook -> develop -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 7.638, + "shotCount": 1, + "avgShotSec": 7.64, + "cutDensity": "low", + "peakAt": 0.5, + "beatHints": [ + "轻缓家居向纯音单节拍铺垫" + ], + "rhythmNotes": [ + "平均 7.6s/镜,整体为 慢节奏。", + "高潮位置约在全片 50%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「顶部窄条极简家装场景主题标题」协同", + "animation": "字幕/标题可能配合「无转场单镜头连贯呈现」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "顶部窄条极简家装场景主题标题", + "stickerUsage": "无多余装饰贴纸,仅保留平台原生标识", + "coverStyle": "低视角入户空间全景居中展示,突出硬装统一色调", + "overlayStyle": "顶部窄条极简家装场景主题标题 / 无多余装饰贴纸,仅保留平台原生标识", + "notes": [ + "画面包装迁移重点:顶部窄条极简家装场景主题标题;无多余装饰贴纸,仅保留平台原生标识;低视角入户空间全景居中展示,突出硬装统一色调", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "无转场单镜头连贯呈现", + "frequency": "低频切换", + "notableTransitions": [ + "无转场单镜头连贯呈现" + ], + "executableTechniques": [ + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻缓家居向纯音单节拍铺垫" + ], + "syncStrategy": "参考蓝图中的 1 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "SLOT-001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 2, + "optional": false + }, + { + "slotId": "SLOT-002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 3.8, + "optional": false + }, + { + "slotId": "SLOT-003", + "segmentRole": "closing", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 1.5, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 7.638s", + "ref": "seed:1FC9E6E0-1756-415A-8CAC-28ED6106FDCA.MOV" + }, + { + "type": "resolution", + "detail": "1280x720 @ 30.249fps" + }, + { + "type": "scene_cut", + "detail": "1 个镜头 / 0 个切点(原始 0 个,已合并 <0.4s 密集检测)", + "ref": "" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 low;模板事件 3 个;音频 onset 2 个", + "ref": "letterbox_frame, ken_burns_in, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 1 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构适配短时长家居空间展示类内容,开场直接用高颜值完整视觉效果抓牢目标用户注意力,中间无干扰的长镜头完整传递空间布局与硬装搭配细节,收尾定格全貌强化用户记忆点,极低的剪辑密度适配慢节奏家居内容的观赏感,避免多余信息干扰用户对空间实际效果的感知。", + "createdAt": "2026-06-08T09:46:36.747Z", + "updatedAt": "2026-06-08T09:46:43.848Z" + }, + { + "id": "pattern_fdefae05", + "scope": "global", + "sourceSampleId": "2df95d7b-3bee-4e08-b7fe-e9c1cc985c39", + "name": "6B90858B-B5F1-42B1-9AAB-A87A857077C3 · 展示模式", + "summary": "该样例是 9s 的 展示 视频,结构为 静态人物锚定开场 -> 动感特效首次触发 -> 视觉张力拉满 -> 动感效果收尾展示,节奏为 中等节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "medium", + "hook", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "generic_next_action", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": false, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:静态人物锚定开场 -> 动感效果收尾展示", + "formula": "静态人物锚定开场 -> 动感特效首次触发 -> 视觉张力拉满 -> 动感效果收尾展示", + "source": { + "filename": "6B90858B-B5F1-42B1-9AAB-A87A857077C3.MOV", + "durationSec": 9.287, + "aspectRatio": "1280:720", + "shotCount": 4 + }, + "segments": [ + { + "role": "hook", + "label": "静态人物锚定开场", + "durationRatio": 0.43, + "intent": "用清晰稳定的室内日常画面建立观众初始视觉认知,形成低运动感的心理预期", + "copyPattern": "中景人物直面镜头静态展示,无多余信息干扰", + "watchingPurpose": "开场抓停:用清晰稳定的室内日常画面建立观众初始视觉认知,形成低运动感的心理预期" + }, + { + "role": "develop", + "label": "动感特效首次触发", + "durationRatio": 0.25, + "intent": "通过动态模糊特效完成室内到户外的场景跳转,打破之前的静态节奏,逐步提升运动感", + "copyPattern": "水平方向运动模糊特效转场,切换陌生户外场景", + "watchingPurpose": "推进主体:通过动态模糊特效完成室内到户外的场景跳转,打破之前的静态节奏,逐步提升运动感" + }, + { + "role": "climax", + "label": "视觉张力拉满", + "durationRatio": 0.13, + "intent": "用全画面失焦的过渡镜头强化眩晕动感效果,把视觉反差拉到峰值", + "copyPattern": "全画面失焦模糊制造视觉断层,放大高速运动的沉浸感", + "watchingPurpose": "放大重点:用全画面失焦的过渡镜头强化眩晕动感效果,把视觉反差拉到峰值" + }, + { + "role": "closing", + "label": "动感效果收尾展示", + "durationRatio": 0.19, + "intent": "呈现高速运动的最终画面,强化整体动感特效的展示效果,留下强记忆点", + "copyPattern": "强动态模糊下的多人快速运动画面收尾,定格动感氛围", + "watchingPurpose": "收束记忆点:呈现高速运动的最终画面,强化整体动感特效的展示效果,留下强记忆点" + } + ], + "pacing": { + "durationSec": 9.287, + "shotCount": 4, + "avgShotSec": 2.32, + "cutDensity": "medium", + "peakAt": 0.7, + "beatHints": [ + "开篇舒缓低鼓点", + "中段节奏快速拉升", + "高潮点重音卡点", + "收尾快节奏收束" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "无顶部标题栏,全程无额外叠加文字", + "stickerUsage": "无装饰性贴纸元素", + "transitionStyle": "动态模糊特效搭配快速硬切实现转场", + "coverStyle": "选取开篇清晰的人物中景画面作为封面,形成和正片动感内容的反差感" + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "无顶部标题栏,全程无额外叠加文字", + "stickerUsage": "无装饰性贴纸元素", + "coverStyle": "选取开篇清晰的人物中景画面作为封面,形成和正片动感内容的反差感", + "overlayStyle": "无顶部标题栏,全程无额外叠加文字 / 无装饰性贴纸元素", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 4 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 9.287, + "bpm": 112, + "beatCount": 17, + "beatStability": 0.6307840616966581, + "onsetDensity": 0.43070959405620757, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571 + ], + "sections": [ + { + "startSec": 0, + "endSec": 1.672, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 1.672, + "endSec": 5.108, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.143, + 4.286 + ] + }, + { + "startSec": 5.108, + "endSec": 6.78, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 6.429 + ] + }, + { + "startSec": 6.78, + "endSec": 9.287, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 8.571 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_2df95d7b-3bee-4e08-b7fe-e9c1cc985c39", + "source": "global_sample", + "durationSec": 9.287, + "music": { + "hasAudio": true, + "durationSec": 9.287, + "bpm": 112, + "beatCount": 17, + "beatStability": 0.6307840616966581, + "onsetDensity": 0.43070959405620757, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571 + ], + "sections": [ + { + "startSec": 0, + "endSec": 1.672, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 1.672, + "endSec": 5.108, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.143, + 4.286 + ] + }, + { + "startSec": 5.108, + "endSec": 6.78, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 6.429 + ] + }, + { + "startSec": 6.78, + "endSec": 9.287, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 8.571 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 4, + "avgShotSec": 2.32, + "peakAt": 0.7, + "cutEveryBeats": 4.357, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "静态人物锚定开场" + }, + { + "eventType": "caption", + "timeSec": 3.993, + "relativeTime": 0.4299558522666092, + "beatIndex": 7, + "phraseIndex": 0, + "nearestBeatSec": 3.75, + "offsetMs": 243, + "segmentRole": "develop", + "strength": "medium", + "description": "动感特效首次触发" + }, + { + "eventType": "cut", + "timeSec": 4.033, + "relativeTime": 0.4342629482071713, + "beatIndex": 8, + "phraseIndex": 1, + "nearestBeatSec": 4.286, + "offsetMs": -253, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 6.315, + "relativeTime": 0.6799827716162378, + "beatIndex": 12, + "phraseIndex": 1, + "nearestBeatSec": 6.429, + "offsetMs": -114, + "segmentRole": "climax", + "strength": "medium", + "description": "视觉张力拉满" + }, + { + "eventType": "cut", + "timeSec": 6.367, + "relativeTime": 0.6855819963389684, + "beatIndex": 12, + "phraseIndex": 1, + "nearestBeatSec": 6.429, + "offsetMs": -62, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 7.522, + "relativeTime": 0.8099493916226983, + "beatIndex": 14, + "phraseIndex": 1, + "nearestBeatSec": 7.5, + "offsetMs": 22, + "segmentRole": "closing", + "strength": "medium", + "description": "动感效果收尾展示" + }, + { + "eventType": "cut", + "timeSec": 7.567, + "relativeTime": 0.8147948745558307, + "beatIndex": 14, + "phraseIndex": 1, + "nearestBeatSec": 7.5, + "offsetMs": 67, + "strength": "strong", + "description": "样例第 3 个切镜点" + } + ], + "cutIntervalsSec": [ + 4.033, + 2.334, + 1.2, + 1.72 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:4 镜,平均 2.3s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_2df95d7b-3bee-4e08-b7fe-e9c1cc985c39", + "source": "global_sample", + "durationSec": 9.287, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": false, + "hasViewportSlides": false, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 1 个,硬切 3 个。", + "真实音频 onset 3 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 4, + "relativeTime": 0.431, + "strength": "medium", + "energyDb": -4.142 + }, + { + "timeSec": 6.5, + "relativeTime": 0.7, + "strength": "strong", + "energyDb": -3.901 + }, + { + "timeSec": 7.5, + "relativeTime": 0.808, + "strength": "strong", + "energyDb": -2.151 + } + ], + "events": [ + { + "kind": "internal_motion", + "timeSec": 2.567, + "relativeTime": 0.276, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 4, + "relativeTime": 0.431, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 6.5, + "relativeTime": 0.7, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 7.5, + "relativeTime": 0.808, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "ken_burns_in", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "静态人物锚定开场 -> 动感特效首次触发 -> 视觉张力拉满 -> 动感效果收尾展示", + "segmentCount": 4, + "segments": [ + { + "role": "hook", + "label": "静态人物锚定开场", + "durationRatio": 0.43, + "intent": "用清晰稳定的室内日常画面建立观众初始视觉认知,形成低运动感的心理预期", + "copyPattern": "中景人物直面镜头静态展示,无多余信息干扰", + "watchingPurpose": "开场抓停:用清晰稳定的室内日常画面建立观众初始视觉认知,形成低运动感的心理预期" + }, + { + "role": "develop", + "label": "动感特效首次触发", + "durationRatio": 0.25, + "intent": "通过动态模糊特效完成室内到户外的场景跳转,打破之前的静态节奏,逐步提升运动感", + "copyPattern": "水平方向运动模糊特效转场,切换陌生户外场景", + "watchingPurpose": "推进主体:通过动态模糊特效完成室内到户外的场景跳转,打破之前的静态节奏,逐步提升运动感" + }, + { + "role": "climax", + "label": "视觉张力拉满", + "durationRatio": 0.13, + "intent": "用全画面失焦的过渡镜头强化眩晕动感效果,把视觉反差拉到峰值", + "copyPattern": "全画面失焦模糊制造视觉断层,放大高速运动的沉浸感", + "watchingPurpose": "放大重点:用全画面失焦的过渡镜头强化眩晕动感效果,把视觉反差拉到峰值" + }, + { + "role": "closing", + "label": "动感效果收尾展示", + "durationRatio": 0.19, + "intent": "呈现高速运动的最终画面,强化整体动感特效的展示效果,留下强记忆点", + "copyPattern": "强动态模糊下的多人快速运动画面收尾,定格动感氛围", + "watchingPurpose": "收束记忆点:呈现高速运动的最终画面,强化整体动感特效的展示效果,留下强记忆点" + } + ], + "notes": [ + "脚本结构由 4 个段落组成,按 hook -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 9.287, + "shotCount": 4, + "avgShotSec": 2.32, + "cutDensity": "medium", + "peakAt": 0.7, + "beatHints": [ + "开篇舒缓低鼓点", + "中段节奏快速拉升", + "高潮点重音卡点", + "收尾快节奏收束" + ], + "rhythmNotes": [ + "平均 2.3s/镜,整体为 中等节奏。", + "高潮位置约在全片 70%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「无顶部标题栏,全程无额外叠加文字」协同", + "animation": "字幕/标题可能配合「动态模糊特效搭配快速硬切实现转场」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "无顶部标题栏,全程无额外叠加文字", + "stickerUsage": "无装饰性贴纸元素", + "coverStyle": "选取开篇清晰的人物中景画面作为封面,形成和正片动感内容的反差感", + "overlayStyle": "无顶部标题栏,全程无额外叠加文字 / 无装饰性贴纸元素", + "notes": [ + "画面包装迁移重点:无顶部标题栏,全程无额外叠加文字;无装饰性贴纸元素;选取开篇清晰的人物中景画面作为封面,形成和正片动感内容的反差感", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "动态模糊特效搭配快速硬切实现转场", + "frequency": "中等频率切换", + "notableTransitions": [ + "动态模糊特效搭配快速硬切实现转场" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "开篇舒缓低鼓点", + "中段节奏快速拉升", + "高潮点重音卡点", + "收尾快节奏收束" + ], + "syncStrategy": "参考蓝图中的 4 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_1", + "segmentRole": "hook", + "requiredAssetTypes": [ + "talking_head", + "b_roll" + ], + "minDurationSec": 3, + "optional": false + }, + { + "slotId": "slot_2", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll", + "usage_demo" + ], + "minDurationSec": 2, + "optional": false + }, + { + "slotId": "slot_3", + "segmentRole": "climax", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 1, + "optional": false + }, + { + "slotId": "slot_4", + "segmentRole": "closing", + "requiredAssetTypes": [ + "b_roll", + "usage_demo" + ], + "minDurationSec": 1.5, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 9.287s", + "ref": "seed:6B90858B-B5F1-42B1-9AAB-A87A857077C3.MOV" + }, + { + "type": "resolution", + "detail": "1280x720 @ 30.205fps" + }, + { + "type": "scene_cut", + "detail": "4 个镜头 / 3 个切点(原始 4 个,已合并 <0.4s 密集检测)", + "ref": "4.03, 6.37, 7.57" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 low;模板事件 4 个;音频 onset 3 个", + "ref": "letterbox_frame, ken_burns_in, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 4 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构先通过较长时长的清晰静态画面给观众建立稳定的视觉预期,后续突然切换快速动态模糊镜头制造强烈的节奏反差,逐步提升动感强度,在极短的9秒时长内最大化视觉冲击力,完全适配视觉特效展示类内容的核心诉求,让观众快速感知特效的动感效果,形成深刻记忆点。", + "createdAt": "2026-06-08T09:46:52.255Z", + "updatedAt": "2026-06-08T09:46:53.834Z" + }, + { + "id": "pattern_0dc087fa", + "scope": "global", + "sourceSampleId": "46f09cd8-f823-4c8d-a8b9-6f2bce6aed3c", + "name": "D9889DC6-0C90-4EC0-85AD-8ED319F566DA · 带货模式", + "summary": "该样例是 12s 的 带货 视频,结构为 暖调场景直出开场 -> 场景氛围铺垫 -> 产品核心展示 -> 氛围收尾留印象,节奏为 慢节奏。", + "videoGenre": "product", + "tags": [ + "product", + "low", + "hook", + "setup", + "develop", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "not_recommended", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": false, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "带货:暖调场景直出开场 -> 氛围收尾留印象", + "formula": "暖调场景直出开场 -> 场景氛围铺垫 -> 产品核心展示 -> 氛围收尾留印象", + "source": { + "filename": "D9889DC6-0C90-4EC0-85AD-8ED319F566DA.MOV", + "durationSec": 12.033, + "aspectRatio": "1280:720", + "shotCount": 1 + }, + "segments": [ + { + "role": "hook", + "label": "暖调场景直出开场", + "durationRatio": 0.2, + "intent": "第一时间抓住对生活美学类内容感兴趣的受众注意力", + "copyPattern": "无台词纯视觉场景直出开场", + "watchingPurpose": "开场抓停:第一时间抓住对生活美学类内容感兴趣的受众注意力" + }, + { + "role": "setup", + "label": "场景氛围铺垫", + "durationRatio": 0.2, + "intent": "交代产品所处的生活化使用场景,降低受众距离感", + "copyPattern": "环境元素自然烘托铺垫", + "watchingPurpose": "建立背景:交代产品所处的生活化使用场景,降低受众距离感" + }, + { + "role": "develop", + "label": "产品核心展示", + "durationRatio": 0.5, + "intent": "清晰完整呈现罐装产品的外观细节与视觉质感", + "copyPattern": "静态居中聚焦产品主体", + "watchingPurpose": "推进主体:清晰完整呈现罐装产品的外观细节与视觉质感" + }, + { + "role": "closing", + "label": "氛围收尾留印象", + "durationRatio": 0.1, + "intent": "让受众对产品的场景化属性形成最终记忆点", + "copyPattern": "画面定格自然收尾", + "watchingPurpose": "收束记忆点:让受众对产品的场景化属性形成最终记忆点" + } + ], + "pacing": { + "durationSec": 12.033, + "shotCount": 1, + "avgShotSec": 12.03, + "cutDensity": "low", + "peakAt": 0.5, + "beatHints": [ + "低音量舒缓轻背景音", + "弱环境白噪音" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "无额外标题栏,仅保留平台默认右下角标识", + "stickerUsage": "无任何装饰性贴纸元素", + "transitionStyle": "单镜头无转场,全程画面静止", + "coverStyle": "产品居中、周边茶具做氛围烘托的暖调场景截图" + }, + "editingTechniques": [ + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "无额外标题栏,仅保留平台默认右下角标识", + "stickerUsage": "无任何装饰性贴纸元素", + "coverStyle": "产品居中、周边茶具做氛围烘托的暖调场景截图", + "overlayStyle": "无额外标题栏,仅保留平台默认右下角标识 / 无任何装饰性贴纸元素", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 12.033, + "bpm": 92, + "beatCount": 18, + "beatStability": 0.45, + "onsetDensity": 0.08310479514667997, + "energyShape": "mid_peak", + "peakAt": 0.5, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.166, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 2.166, + "endSec": 6.618, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609, + 5.217 + ] + }, + { + "startSec": 6.618, + "endSec": 8.784, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 7.826 + ] + }, + { + "startSec": 8.784, + "endSec": 12.033, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 10.435 + ] + } + ], + "tags": [ + "product", + "density:low", + "bpm:92", + "shape:mid_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_46f09cd8-f823-4c8d-a8b9-6f2bce6aed3c", + "source": "global_sample", + "durationSec": 12.033, + "music": { + "hasAudio": true, + "durationSec": 12.033, + "bpm": 92, + "beatCount": 18, + "beatStability": 0.45, + "onsetDensity": 0.08310479514667997, + "energyShape": "mid_peak", + "peakAt": 0.5, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.166, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 2.166, + "endSec": 6.618, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609, + 5.217 + ] + }, + { + "startSec": 6.618, + "endSec": 8.784, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 7.826 + ] + }, + { + "startSec": 8.784, + "endSec": 12.033, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 10.435 + ] + } + ], + "tags": [ + "product", + "density:low", + "bpm:92", + "shape:mid_peak" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 1, + "avgShotSec": 12.03, + "peakAt": 0.5, + "cutEveryBeats": 18.451, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "暖调场景直出开场" + }, + { + "eventType": "caption", + "timeSec": 2.407, + "relativeTime": 0.20003324191805869, + "beatIndex": 4, + "phraseIndex": 0, + "nearestBeatSec": 2.609, + "offsetMs": -202, + "segmentRole": "setup", + "strength": "medium", + "description": "场景氛围铺垫" + }, + { + "eventType": "caption", + "timeSec": 4.814, + "relativeTime": 0.40006648383611737, + "beatIndex": 7, + "phraseIndex": 0, + "nearestBeatSec": 4.565, + "offsetMs": 249, + "segmentRole": "develop", + "strength": "medium", + "description": "产品核心展示" + }, + { + "eventType": "caption", + "timeSec": 10.831, + "relativeTime": 0.9001080362336907, + "beatIndex": 17, + "phraseIndex": 2, + "nearestBeatSec": 11.087, + "offsetMs": -256, + "segmentRole": "closing", + "strength": "medium", + "description": "氛围收尾留印象" + } + ], + "cutIntervalsSec": [ + 12.033 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:1 镜,平均 12.0s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_46f09cd8-f823-4c8d-a8b9-6f2bce6aed3c", + "source": "global_sample", + "durationSec": 12.033, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 4 个,硬切 0 个。", + "真实音频 onset 3 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 6, + "relativeTime": 0.499, + "strength": "medium", + "energyDb": -5.783 + }, + { + "timeSec": 8.5, + "relativeTime": 0.706, + "strength": "strong", + "energyDb": -5.519 + }, + { + "timeSec": 10.5, + "relativeTime": 0.873, + "strength": "strong", + "energyDb": -5.59 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 0.833, + "relativeTime": 0.069, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.333, + "relativeTime": 0.194, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.8, + "relativeTime": 0.233, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.567, + "relativeTime": 0.38, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 6, + "relativeTime": 0.499, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 8.5, + "relativeTime": 0.706, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 10.5, + "relativeTime": 0.873, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "ken_burns_in", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "暖调场景直出开场 -> 场景氛围铺垫 -> 产品核心展示 -> 氛围收尾留印象", + "segmentCount": 4, + "segments": [ + { + "role": "hook", + "label": "暖调场景直出开场", + "durationRatio": 0.2, + "intent": "第一时间抓住对生活美学类内容感兴趣的受众注意力", + "copyPattern": "无台词纯视觉场景直出开场", + "watchingPurpose": "开场抓停:第一时间抓住对生活美学类内容感兴趣的受众注意力" + }, + { + "role": "setup", + "label": "场景氛围铺垫", + "durationRatio": 0.2, + "intent": "交代产品所处的生活化使用场景,降低受众距离感", + "copyPattern": "环境元素自然烘托铺垫", + "watchingPurpose": "建立背景:交代产品所处的生活化使用场景,降低受众距离感" + }, + { + "role": "develop", + "label": "产品核心展示", + "durationRatio": 0.5, + "intent": "清晰完整呈现罐装产品的外观细节与视觉质感", + "copyPattern": "静态居中聚焦产品主体", + "watchingPurpose": "推进主体:清晰完整呈现罐装产品的外观细节与视觉质感" + }, + { + "role": "closing", + "label": "氛围收尾留印象", + "durationRatio": 0.1, + "intent": "让受众对产品的场景化属性形成最终记忆点", + "copyPattern": "画面定格自然收尾", + "watchingPurpose": "收束记忆点:让受众对产品的场景化属性形成最终记忆点" + } + ], + "notes": [ + "脚本结构由 4 个段落组成,按 hook -> setup -> develop -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 12.033, + "shotCount": 1, + "avgShotSec": 12.03, + "cutDensity": "low", + "peakAt": 0.5, + "beatHints": [ + "低音量舒缓轻背景音", + "弱环境白噪音" + ], + "rhythmNotes": [ + "平均 12.0s/镜,整体为 慢节奏。", + "高潮位置约在全片 50%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「无额外标题栏,仅保留平台默认右下角标识」协同", + "animation": "字幕/标题可能配合「单镜头无转场,全程画面静止」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "无额外标题栏,仅保留平台默认右下角标识", + "stickerUsage": "无任何装饰性贴纸元素", + "coverStyle": "产品居中、周边茶具做氛围烘托的暖调场景截图", + "overlayStyle": "无额外标题栏,仅保留平台默认右下角标识 / 无任何装饰性贴纸元素", + "notes": [ + "画面包装迁移重点:无额外标题栏,仅保留平台默认右下角标识;无任何装饰性贴纸元素;产品居中、周边茶具做氛围烘托的暖调场景截图", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "单镜头无转场,全程画面静止", + "frequency": "低频切换", + "notableTransitions": [ + "单镜头无转场,全程画面静止" + ], + "executableTechniques": [ + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "低音量舒缓轻背景音", + "弱环境白噪音" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 2, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "product_closeup" + ], + "minDurationSec": 6, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "closing", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 1, + "optional": true + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 12.033s", + "ref": "seed:D9889DC6-0C90-4EC0-85AD-8ED319F566DA.MOV" + }, + { + "type": "resolution", + "detail": "1280x720 @ 30fps" + }, + { + "type": "scene_cut", + "detail": "1 个镜头 / 0 个切点(原始 0 个,已合并 <0.4s 密集检测)", + "ref": "" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 low;模板事件 7 个;音频 onset 3 个", + "ref": "letterbox_frame, ken_burns_in, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 1 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "采用无冗余剪辑、无台词的低干扰单镜头展示模式,开篇直接用暖调生活化场景快速筛选匹配受众,全程将产品放在视觉核心位置,通过周边茶具的场景烘托强化产品的日常使用属性,完全避免多余信息分散观众注意力,适配极短时长的产品轻展示场景,让观众在低认知负担下快速建立对产品外观的清晰认知。", + "createdAt": "2026-06-08T09:47:51.922Z", + "updatedAt": "2026-06-08T09:47:53.829Z" + }, + { + "id": "pattern_374d3c8b", + "scope": "global", + "sourceSampleId": "8cf3019f-844d-4a62-bedb-2a57bb42cbc6", + "name": "v0d00fg10000cdhrldrc77udnd63o61g · 展示模式", + "summary": "该样例是 100s 的 展示 视频,结构为 主题定调开场 -> 基础场景铺垫 -> 细节内容展开 -> 情绪高潮呈现 -> 引导行动收尾,节奏为 中等节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "story_candidate", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "weak", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:主题定调开场 -> 引导行动收尾", + "formula": "主题定调开场 -> 基础场景铺垫 -> 细节内容展开 -> 情绪高潮呈现 -> 引导行动收尾", + "source": { + "filename": "v0d00fg10000cdhrldrc77udnd63o61g.MP4", + "durationSec": 99.856, + "aspectRatio": "1080:720", + "shotCount": 20 + }, + "segments": [ + { + "role": "hook", + "label": "主题定调开场", + "durationRatio": 0.05, + "intent": "第一时间明确作品主题与怀旧暖调风格,快速筛选目标受众", + "copyPattern": "主题标识+作品属性前置露出", + "watchingPurpose": "开场抓停:第一时间明确作品主题与怀旧暖调风格,快速筛选目标受众" + }, + { + "role": "setup", + "label": "基础场景铺垫", + "durationRatio": 0.15, + "intent": "快速铺陈客家老城区的典型环境特征,建立地域场景认知", + "copyPattern": "多标志性生活场景快速串联", + "watchingPurpose": "建立背景:快速铺陈客家老城区的典型环境特征,建立地域场景认知" + }, + { + "role": "develop", + "label": "细节内容展开", + "durationRatio": 0.6, + "intent": "分层输出大量充满烟火气的街头摄影细节画面,逐步积累情绪氛围感", + "copyPattern": "生活化细节特写串联+氛围感短句点缀", + "watchingPurpose": "推进主体:分层输出大量充满烟火气的街头摄影细节画面,逐步积累情绪氛围感" + }, + { + "role": "climax", + "label": "情绪高潮呈现", + "durationRatio": 0.15, + "intent": "通过带人物互动的暖光场景将治愈感拉满,强化观众对慢生活氛围的感知", + "copyPattern": "黄金时刻光影渲染+核心金句输出", + "watchingPurpose": "放大重点:通过带人物互动的暖光场景将治愈感拉满,强化观众对慢生活氛围的感知" + }, + { + "role": "closing", + "label": "引导行动收尾", + "durationRatio": 0.05, + "intent": "清晰告知观众获取更多内容的路径,完成引流闭环", + "copyPattern": "明确搜索指令+创作者信息集中露出", + "watchingPurpose": "收束记忆点:清晰告知观众获取更多内容的路径,完成引流闭环" + } + ], + "pacing": { + "durationSec": 99.856, + "shotCount": 20, + "avgShotSec": 4.99, + "cutDensity": "medium", + "peakAt": 0.75, + "beatHints": [ + "抒情吉他分解和弦铺垫", + "暖调钢琴旋律递进", + "音量渐弱收尾" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "复古暖调衬线字体,低透明度叠加不遮挡画面主体", + "stickerUsage": "小尺寸账号水印固定在画面右下角,无多余装饰贴纸", + "transitionStyle": "大部分场景用硬切衔接,情绪节点处用淡入淡出过渡", + "coverStyle": "选取暖光斜照老巷的高光画面,叠加极简作品主题文字" + }, + "storySkeleton": { + "arcType": "展示 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "第一时间明确作品主题与怀旧暖调风格,快速筛选目标受众", + "快速铺陈客家老城区的典型环境特征,建立地域场景认知", + "分层输出大量充满烟火气的街头摄影细节画面,逐步积累情绪氛围感", + "通过带人物互动的暖光场景将治愈感拉满,强化观众对慢生活氛围的感知", + "清晰告知观众获取更多内容的路径,完成引流闭环" + ], + "hookStyle": "主题标识+作品属性前置露出", + "turnOrProofStyle": "生活化细节特写串联+氛围感短句点缀", + "payoffStyle": "明确搜索指令+创作者信息集中露出", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "showcase" + ], + "assetRequirements": [ + "b_roll", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_crossfade", + "name": "可执行转场:crossfade", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "crossfade", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=crossfade,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunction": [ + "opening_hook", + "transition", + "cta" + ], + "appliesToAssetType": [ + "text" + ], + "appliesToVisualCluster": [], + "beatPlacement": "phrase boundary or shot boundary", + "cardAnimationPreset": "soft_crossfade", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "remotion", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ], + "packagingPattern": { + "titleBarStyle": "复古暖调衬线字体,低透明度叠加不遮挡画面主体", + "stickerUsage": "小尺寸账号水印固定在画面右下角,无多余装饰贴纸", + "coverStyle": "选取暖光斜照老巷的高光画面,叠加极简作品主题文字", + "overlayStyle": "复古暖调衬线字体,低透明度叠加不遮挡画面主体 / 小尺寸账号水印固定在画面右下角,无多余装饰贴纸", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 99.856, + "bpm": 112, + "beatCount": 186, + "beatStability": 0.7577067298204065, + "onsetDensity": 0.20028841531805802, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714, + 30, + 34.286, + 38.571, + 42.857, + 47.143, + 51.429, + 55.714, + 60, + 64.286, + 68.571, + 72.857, + 77.143, + 81.429, + 85.714, + 90, + 94.286, + 98.571 + ], + "sections": [ + { + "startSec": 0, + "endSec": 17.974, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143 + ] + }, + { + "startSec": 17.974, + "endSec": 67.902, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429 + ] + }, + { + "startSec": 67.902, + "endSec": 85.876, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714 + ] + }, + { + "startSec": 85.876, + "endSec": 99.856, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_8cf3019f-844d-4a62-bedb-2a57bb42cbc6", + "source": "global_sample", + "durationSec": 99.856, + "music": { + "hasAudio": true, + "durationSec": 99.856, + "bpm": 112, + "beatCount": 186, + "beatStability": 0.7577067298204065, + "onsetDensity": 0.20028841531805802, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714, + 30, + 34.286, + 38.571, + 42.857, + 47.143, + 51.429, + 55.714, + 60, + 64.286, + 68.571, + 72.857, + 77.143, + 81.429, + 85.714, + 90, + 94.286, + 98.571 + ], + "sections": [ + { + "startSec": 0, + "endSec": 17.974, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143 + ] + }, + { + "startSec": 17.974, + "endSec": 67.902, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429 + ] + }, + { + "startSec": 67.902, + "endSec": 85.876, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714 + ] + }, + { + "startSec": 85.876, + "endSec": 99.856, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 20, + "avgShotSec": 4.99, + "peakAt": 0.75, + "cutEveryBeats": 9.458, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "主题定调开场" + }, + { + "eventType": "caption", + "timeSec": 4.993, + "relativeTime": 0.050002002884153185, + "beatIndex": 9, + "phraseIndex": 1, + "nearestBeatSec": 4.821, + "offsetMs": 172, + "segmentRole": "setup", + "strength": "medium", + "description": "基础场景铺垫" + }, + { + "eventType": "cut", + "timeSec": 5.1, + "relativeTime": 0.05107354590610479, + "beatIndex": 10, + "phraseIndex": 1, + "nearestBeatSec": 5.357, + "offsetMs": -257, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.2, + "relativeTime": 0.11216151257811248, + "beatIndex": 21, + "phraseIndex": 2, + "nearestBeatSec": 11.25, + "offsetMs": -50, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 16.475, + "relativeTime": 0.1649875821182503, + "beatIndex": 31, + "phraseIndex": 3, + "nearestBeatSec": 16.607, + "offsetMs": -132, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 19.971, + "relativeTime": 0.19999799711584684, + "beatIndex": 37, + "phraseIndex": 4, + "nearestBeatSec": 19.821, + "offsetMs": 150, + "segmentRole": "develop", + "strength": "medium", + "description": "细节内容展开" + }, + { + "eventType": "cut", + "timeSec": 21.941, + "relativeTime": 0.21972640602467552, + "beatIndex": 41, + "phraseIndex": 5, + "nearestBeatSec": 21.964, + "offsetMs": -23, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 24.775, + "relativeTime": 0.24810727447524436, + "beatIndex": 46, + "phraseIndex": 5, + "nearestBeatSec": 24.643, + "offsetMs": 132, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 28.211, + "relativeTime": 0.28251682422688673, + "beatIndex": 53, + "phraseIndex": 6, + "nearestBeatSec": 28.393, + "offsetMs": -182, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 31.477, + "relativeTime": 0.3152239224483256, + "beatIndex": 59, + "phraseIndex": 7, + "nearestBeatSec": 31.607, + "offsetMs": -130, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 35.502, + "relativeTime": 0.3555319660310848, + "beatIndex": 66, + "phraseIndex": 8, + "nearestBeatSec": 35.357, + "offsetMs": 145, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 40.768, + "relativeTime": 0.4082679057843295, + "beatIndex": 76, + "phraseIndex": 9, + "nearestBeatSec": 40.714, + "offsetMs": 54, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 43.802, + "relativeTime": 0.43865165838807885, + "beatIndex": 82, + "phraseIndex": 10, + "nearestBeatSec": 43.929, + "offsetMs": -127, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 48.524, + "relativeTime": 0.48593975324467237, + "beatIndex": 91, + "phraseIndex": 11, + "nearestBeatSec": 48.75, + "offsetMs": -226, + "strength": "medium", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 53.124, + "relativeTime": 0.5320060887678257, + "beatIndex": 99, + "phraseIndex": 12, + "nearestBeatSec": 53.036, + "offsetMs": 88, + "strength": "medium", + "description": "样例第 12 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 59.457, + "relativeTime": 0.5954274154782888, + "beatIndex": 111, + "phraseIndex": 13, + "nearestBeatSec": 59.464, + "offsetMs": -7, + "strength": "medium", + "description": "样例第 13 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 63.824, + "relativeTime": 0.6391603909629867, + "beatIndex": 119, + "phraseIndex": 14, + "nearestBeatSec": 63.75, + "offsetMs": 74, + "strength": "medium", + "description": "样例第 14 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 68.977, + "relativeTime": 0.6907647011696845, + "beatIndex": 129, + "phraseIndex": 16, + "nearestBeatSec": 69.107, + "offsetMs": -130, + "strength": "medium", + "description": "样例第 15 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 74.044, + "relativeTime": 0.7415077711905144, + "beatIndex": 138, + "phraseIndex": 17, + "nearestBeatSec": 73.929, + "offsetMs": 115, + "strength": "medium", + "description": "样例第 16 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 78.411, + "relativeTime": 0.7852407466752124, + "beatIndex": 146, + "phraseIndex": 18, + "nearestBeatSec": 78.214, + "offsetMs": 197, + "strength": "medium", + "description": "样例第 17 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 79.885, + "relativeTime": 0.8000020028841532, + "beatIndex": 149, + "phraseIndex": 18, + "nearestBeatSec": 79.821, + "offsetMs": 64, + "segmentRole": "climax", + "strength": "medium", + "description": "情绪高潮呈现" + }, + { + "eventType": "cut", + "timeSec": 86.044, + "relativeTime": 0.8616808203813492, + "beatIndex": 161, + "phraseIndex": 20, + "nearestBeatSec": 86.25, + "offsetMs": -206, + "strength": "medium", + "description": "样例第 18 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 94.863, + "relativeTime": 0.9499979971158469, + "beatIndex": 177, + "phraseIndex": 22, + "nearestBeatSec": 94.821, + "offsetMs": 42, + "segmentRole": "closing", + "strength": "medium", + "description": "引导行动收尾" + }, + { + "eventType": "cut", + "timeSec": 96.856, + "relativeTime": 0.9699567377022913, + "beatIndex": 181, + "phraseIndex": 22, + "nearestBeatSec": 96.964, + "offsetMs": -108, + "strength": "strong", + "description": "样例第 19 个切镜点" + } + ], + "cutIntervalsSec": [ + 5.1, + 6.1, + 5.275, + 5.466, + 2.834, + 3.436, + 3.266, + 4.025, + 5.266, + 3.034, + 4.722, + 4.6, + 6.333, + 4.367, + 5.153, + 5.067, + 4.367, + 7.633, + 10.812, + 3 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:20 镜,平均 5.0s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_8cf3019f-844d-4a62-bedb-2a57bb42cbc6", + "source": "global_sample", + "durationSec": 99.856, + "sourceAspect": "1080:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1072:720", + "x": 0.004, + "y": 0, + "width": 0.993, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": false, + "hasViewportSlides": true, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 2 个,硬切 19 个。", + "真实音频 onset 20 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 29.952, + "relativeTime": 0.3, + "strength": "weak", + "energyDb": -16.759 + }, + { + "timeSec": 31.952, + "relativeTime": 0.32, + "strength": "medium", + "energyDb": -16.519 + }, + { + "timeSec": 37.429, + "relativeTime": 0.375, + "strength": "weak", + "energyDb": -17.259 + }, + { + "timeSec": 38.929, + "relativeTime": 0.39, + "strength": "strong", + "energyDb": -14.054 + }, + { + "timeSec": 45.925, + "relativeTime": 0.46, + "strength": "weak", + "energyDb": -16.933 + }, + { + "timeSec": 47.425, + "relativeTime": 0.475, + "strength": "medium", + "energyDb": -16.361 + }, + { + "timeSec": 49.901, + "relativeTime": 0.5, + "strength": "medium", + "energyDb": -15.61 + }, + { + "timeSec": 55.401, + "relativeTime": 0.555, + "strength": "weak", + "energyDb": -17.163 + }, + { + "timeSec": 61.398, + "relativeTime": 0.615, + "strength": "strong", + "energyDb": -13.888 + }, + { + "timeSec": 63.898, + "relativeTime": 0.64, + "strength": "strong", + "energyDb": -12.003 + }, + { + "timeSec": 65.398, + "relativeTime": 0.655, + "strength": "strong", + "energyDb": -13.616 + }, + { + "timeSec": 69.375, + "relativeTime": 0.695, + "strength": "strong", + "energyDb": -13.319 + }, + { + "timeSec": 71.375, + "relativeTime": 0.715, + "strength": "strong", + "energyDb": -13.965 + }, + { + "timeSec": 72.375, + "relativeTime": 0.725, + "strength": "medium", + "energyDb": -15.551 + }, + { + "timeSec": 73.375, + "relativeTime": 0.735, + "strength": "strong", + "energyDb": -13.746 + }, + { + "timeSec": 77.356, + "relativeTime": 0.775, + "strength": "weak", + "energyDb": -17.072 + }, + { + "timeSec": 79.356, + "relativeTime": 0.795, + "strength": "weak", + "energyDb": -16.711 + }, + { + "timeSec": 82.356, + "relativeTime": 0.825, + "strength": "strong", + "energyDb": -15.011 + }, + { + "timeSec": 83.356, + "relativeTime": 0.835, + "strength": "strong", + "energyDb": -14.465 + }, + { + "timeSec": 89.352, + "relativeTime": 0.895, + "strength": "medium", + "energyDb": -16.206 + } + ], + "events": [ + { + "kind": "internal_motion", + "timeSec": 1.067, + "relativeTime": 0.011, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 1.467, + "relativeTime": 0.015, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 29.952, + "relativeTime": 0.3, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 31.952, + "relativeTime": 0.32, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 37.429, + "relativeTime": 0.375, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 38.929, + "relativeTime": 0.39, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 45.925, + "relativeTime": 0.46, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 47.425, + "relativeTime": 0.475, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 49.901, + "relativeTime": 0.5, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 55.401, + "relativeTime": 0.555, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 61.398, + "relativeTime": 0.615, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 63.898, + "relativeTime": 0.64, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 65.398, + "relativeTime": 0.655, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 69.375, + "relativeTime": 0.695, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "ken_burns_in", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "主题定调开场 -> 基础场景铺垫 -> 细节内容展开 -> 情绪高潮呈现 -> 引导行动收尾", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "主题定调开场", + "durationRatio": 0.05, + "intent": "第一时间明确作品主题与怀旧暖调风格,快速筛选目标受众", + "copyPattern": "主题标识+作品属性前置露出", + "watchingPurpose": "开场抓停:第一时间明确作品主题与怀旧暖调风格,快速筛选目标受众" + }, + { + "role": "setup", + "label": "基础场景铺垫", + "durationRatio": 0.15, + "intent": "快速铺陈客家老城区的典型环境特征,建立地域场景认知", + "copyPattern": "多标志性生活场景快速串联", + "watchingPurpose": "建立背景:快速铺陈客家老城区的典型环境特征,建立地域场景认知" + }, + { + "role": "develop", + "label": "细节内容展开", + "durationRatio": 0.6, + "intent": "分层输出大量充满烟火气的街头摄影细节画面,逐步积累情绪氛围感", + "copyPattern": "生活化细节特写串联+氛围感短句点缀", + "watchingPurpose": "推进主体:分层输出大量充满烟火气的街头摄影细节画面,逐步积累情绪氛围感" + }, + { + "role": "climax", + "label": "情绪高潮呈现", + "durationRatio": 0.15, + "intent": "通过带人物互动的暖光场景将治愈感拉满,强化观众对慢生活氛围的感知", + "copyPattern": "黄金时刻光影渲染+核心金句输出", + "watchingPurpose": "放大重点:通过带人物互动的暖光场景将治愈感拉满,强化观众对慢生活氛围的感知" + }, + { + "role": "closing", + "label": "引导行动收尾", + "durationRatio": 0.05, + "intent": "清晰告知观众获取更多内容的路径,完成引流闭环", + "copyPattern": "明确搜索指令+创作者信息集中露出", + "watchingPurpose": "收束记忆点:清晰告知观众获取更多内容的路径,完成引流闭环" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 99.856, + "shotCount": 20, + "avgShotSec": 4.99, + "cutDensity": "medium", + "peakAt": 0.75, + "beatHints": [ + "抒情吉他分解和弦铺垫", + "暖调钢琴旋律递进", + "音量渐弱收尾" + ], + "rhythmNotes": [ + "平均 5.0s/镜,整体为 中等节奏。", + "高潮位置约在全片 75%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「复古暖调衬线字体,低透明度叠加不遮挡画面主体」协同", + "animation": "字幕/标题可能配合「大部分场景用硬切衔接,情绪节点处用淡入淡出过渡」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "复古暖调衬线字体,低透明度叠加不遮挡画面主体", + "stickerUsage": "小尺寸账号水印固定在画面右下角,无多余装饰贴纸", + "coverStyle": "选取暖光斜照老巷的高光画面,叠加极简作品主题文字", + "overlayStyle": "复古暖调衬线字体,低透明度叠加不遮挡画面主体 / 小尺寸账号水印固定在画面右下角,无多余装饰贴纸", + "notes": [ + "画面包装迁移重点:复古暖调衬线字体,低透明度叠加不遮挡画面主体;小尺寸账号水印固定在画面右下角,无多余装饰贴纸;选取暖光斜照老巷的高光画面,叠加极简作品主题文字", + "模板画幅:letterbox_frame,viewport=1072:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "大部分场景用硬切衔接,情绪节点处用淡入淡出过渡", + "frequency": "中等频率切换", + "notableTransitions": [ + "大部分场景用硬切衔接,情绪节点处用淡入淡出过渡" + ], + "executableTechniques": [ + { + "id": "tech_transition_crossfade", + "name": "可执行转场:crossfade", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "crossfade", + "implementationNotes": "已映射到 Timeline.transitionPreset=crossfade,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunctions": [ + "opening_hook", + "transition", + "cta" + ], + "requiredRenderer": "remotion", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "抒情吉他分解和弦铺垫", + "暖调钢琴旋律递进", + "音量渐弱收尾" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 5, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "setup", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 15, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 60, + "optional": false + }, + { + "slotId": "slot_004", + "segmentRole": "climax", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 15, + "optional": false + }, + { + "slotId": "slot_005", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 99.856s", + "ref": "seed:v0d00fg10000cdhrldrc77udnd63o61g.MP4" + }, + { + "type": "resolution", + "detail": "1080x720 @ 29.973fps" + }, + { + "type": "scene_cut", + "detail": "20 个镜头 / 19 个切点(原始 19 个,已合并 <0.4s 密集检测)", + "ref": "5.10, 11.20, 16.48, 21.94, 24.77, 28.21, 31.48, 35.50, 40.77, 43.80, 48.52, 53.12, 59.46, 63.82, 68.98, 74.04, 78.41, 86.04, 96.86" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 low;模板事件 14 个;音频 onset 20 个", + "ref": "letterbox_frame, ken_burns_in, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "从观众观看体验出发,开场快速定调精准抓取喜爱人文纪实、怀旧烟火内容的受众注意力,铺垫阶段快速建立老城区的整体场景认知,主体展开阶段用大量细节画面逐步积累氛围感,高潮部分通过带人物的暖光场景把治愈情绪推到峰值,最后清晰给出行动指令完成引流,中等密度的镜头切换节奏适配抒情怀旧的内容调性,不会破坏整体松弛的观看感受。", + "createdAt": "2026-06-08T09:47:36.325Z", + "updatedAt": "2026-06-08T09:48:04.838Z" + }, + { + "id": "pattern_5d7b282f", + "scope": "global", + "sourceSampleId": "06fb124e-48f2-41e8-93db-1c5fc2ecb280", + "name": "v0d00fg10000d7nm3pfog65vfhp87tlg · 带货模式", + "summary": "该样例是 25s 的 带货 视频,结构为 反常全景视角开场 -> 多场景实拍效果展示 -> 产品实体出镜溯源 -> 极简黑屏收尾,节奏为 中等节奏。", + "videoGenre": "product", + "tags": [ + "product", + "medium", + "hook", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "full_story", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "primary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "带货:反常全景视角开场 -> 极简黑屏收尾", + "formula": "反常全景视角开场 -> 多场景实拍效果展示 -> 产品实体出镜溯源 -> 极简黑屏收尾", + "source": { + "filename": "v0d00fg10000d7nm3pfog65vfhp87tlg.MP4", + "durationSec": 25.169, + "aspectRatio": "720:1280", + "shotCount": 8 + }, + "segments": [ + { + "role": "hook", + "label": "反常全景视角开场", + "durationRatio": 0.32, + "intent": "用反常规的鱼眼局部画面第一时间打破观众视觉惯性,避免划走", + "copyPattern": "非常规局部视角+产品标识前置露出", + "watchingPurpose": "开场抓停:用反常规的鱼眼局部画面第一时间打破观众视觉惯性,避免划走" + }, + { + "role": "develop", + "label": "多场景实拍效果展示", + "durationRatio": 0.43, + "intent": "通过不同出行场景的鲜活画面,直观呈现全景相机的畸变特色与全场景适配性", + "copyPattern": "多场景快切+人物互动动作强化画面感染力", + "watchingPurpose": "推进主体:通过不同出行场景的鲜活画面,直观呈现全景相机的畸变特色与全场景适配性" + }, + { + "role": "climax", + "label": "产品实体出镜溯源", + "durationRatio": 0.13, + "intent": "把前面所有场景的拍摄效果和产品本身做关联,完成效果逻辑闭环", + "copyPattern": "开篇场景回溯+手持产品实体露出", + "watchingPurpose": "放大重点:把前面所有场景的拍摄效果和产品本身做关联,完成效果逻辑闭环" + }, + { + "role": "closing", + "label": "极简黑屏收尾", + "durationRatio": 0.12, + "intent": "用无信息的黑屏给观众留出前面所有美好画面的回味空间", + "copyPattern": "纯暗调黑屏无冗余元素收尾", + "watchingPurpose": "收束记忆点:用无信息的黑屏给观众留出前面所有美好画面的回味空间" + } + ], + "pacing": { + "durationSec": 25.169, + "shotCount": 8, + "avgShotSec": 3.15, + "cutDensity": "medium", + "peakAt": 0.75, + "beatHints": [ + "轻快旅行向鼓点BGM", + "场景切换卡点BGM重音" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "左上角悬浮小字产品名称,无大尺寸标题栏", + "stickerUsage": "无额外装饰贴纸,仅保留平台账号标识", + "transitionStyle": "场景切换使用快切/闪切,无复杂转场特效", + "coverStyle": "鱼眼全景畸变局部人物画面叠加产品名称文字" + }, + "storySkeleton": { + "arcType": "带货 / hook -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "用反常规的鱼眼局部画面第一时间打破观众视觉惯性,避免划走", + "通过不同出行场景的鲜活画面,直观呈现全景相机的畸变特色与全场景适配性", + "把前面所有场景的拍摄效果和产品本身做关联,完成效果逻辑闭环", + "用无信息的黑屏给观众留出前面所有美好画面的回味空间" + ], + "hookStyle": "非常规局部视角+产品标识前置露出", + "turnOrProofStyle": "多场景快切+人物互动动作强化画面感染力", + "payoffStyle": "纯暗调黑屏无冗余元素收尾", + "requiredStoryFunctions": [ + "opening_hook", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "product" + ], + "assetRequirements": [ + "product_closeup", + "b_roll", + "usage_demo", + "talking_head", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "snap_cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "high", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "左上角悬浮小字产品名称,无大尺寸标题栏", + "stickerUsage": "无额外装饰贴纸,仅保留平台账号标识", + "coverStyle": "鱼眼全景畸变局部人物画面叠加产品名称文字", + "overlayStyle": "左上角悬浮小字产品名称,无大尺寸标题栏 / 无额外装饰贴纸,仅保留平台账号标识", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 25.169, + "bpm": 112, + "beatCount": 47, + "beatStability": 0.5678618747929778, + "onsetDensity": 0.31785132504271124, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 4.53, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286 + ] + }, + { + "startSec": 4.53, + "endSec": 17.115, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 6.429, + 8.571, + 10.714, + 12.857, + 15 + ] + }, + { + "startSec": 17.115, + "endSec": 21.645, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 17.143, + 19.286, + 21.429 + ] + }, + { + "startSec": 21.645, + "endSec": 25.169, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 23.571 + ] + } + ], + "tags": [ + "product", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_06fb124e-48f2-41e8-93db-1c5fc2ecb280", + "source": "global_sample", + "durationSec": 25.169, + "music": { + "hasAudio": true, + "durationSec": 25.169, + "bpm": 112, + "beatCount": 47, + "beatStability": 0.5678618747929778, + "onsetDensity": 0.31785132504271124, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 4.53, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286 + ] + }, + { + "startSec": 4.53, + "endSec": 17.115, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 6.429, + 8.571, + 10.714, + 12.857, + 15 + ] + }, + { + "startSec": 17.115, + "endSec": 21.645, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 17.143, + 19.286, + 21.429 + ] + }, + { + "startSec": 21.645, + "endSec": 25.169, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 23.571 + ] + } + ], + "tags": [ + "product", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 8, + "avgShotSec": 3.15, + "peakAt": 0.75, + "cutEveryBeats": 5.635, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "反常全景视角开场" + }, + { + "eventType": "caption", + "timeSec": 8.054, + "relativeTime": 0.31999682148674957, + "beatIndex": 15, + "phraseIndex": 1, + "nearestBeatSec": 8.036, + "offsetMs": 18, + "segmentRole": "develop", + "strength": "medium", + "description": "多场景实拍效果展示" + }, + { + "eventType": "cut", + "timeSec": 8.117, + "relativeTime": 0.32249990067146095, + "beatIndex": 15, + "phraseIndex": 1, + "nearestBeatSec": 8.036, + "offsetMs": 81, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 10.717, + "relativeTime": 0.4258015813103421, + "beatIndex": 20, + "phraseIndex": 2, + "nearestBeatSec": 10.714, + "offsetMs": 3, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.883, + "relativeTime": 0.47212841193531724, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 11.786, + "offsetMs": 97, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 14.75, + "relativeTime": 0.5860383805474989, + "beatIndex": 28, + "phraseIndex": 3, + "nearestBeatSec": 15, + "offsetMs": -250, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 18.15, + "relativeTime": 0.7211251936906511, + "beatIndex": 34, + "phraseIndex": 4, + "nearestBeatSec": 18.214, + "offsetMs": -64, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 18.877, + "relativeTime": 0.7500099328539075, + "beatIndex": 35, + "phraseIndex": 4, + "nearestBeatSec": 18.75, + "offsetMs": 127, + "segmentRole": "climax", + "strength": "medium", + "description": "产品实体出镜溯源" + }, + { + "eventType": "cut", + "timeSec": 18.883, + "relativeTime": 0.7502483213476896, + "beatIndex": 35, + "phraseIndex": 4, + "nearestBeatSec": 18.75, + "offsetMs": 133, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 22.149, + "relativeTime": 0.8800111247963766, + "beatIndex": 41, + "phraseIndex": 5, + "nearestBeatSec": 21.964, + "offsetMs": 185, + "segmentRole": "closing", + "strength": "medium", + "description": "极简黑屏收尾" + }, + { + "eventType": "cut", + "timeSec": 22.15, + "relativeTime": 0.8800508562120067, + "beatIndex": 41, + "phraseIndex": 5, + "nearestBeatSec": 21.964, + "offsetMs": 186, + "strength": "strong", + "description": "样例第 7 个切镜点" + } + ], + "cutIntervalsSec": [ + 8.117, + 2.6, + 1.166, + 2.867, + 3.4, + 0.733, + 3.267, + 3.019 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:8 镜,平均 3.1s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_06fb124e-48f2-41e8-93db-1c5fc2ecb280", + "source": "global_sample", + "durationSec": 25.169, + "sourceAspect": "720:1280", + "targetCanvasAspect": "9:16", + "layoutPreset": "full_bleed", + "frameStyle": { + "backgroundColor": "#050505", + "matte": false, + "roundedMask": false, + "labelStyle": "none", + "viewport": { + "aspectRatio": "720:1280", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "high", + "hasMaskReveals": true, + "hasViewportSlides": false, + "preferredMotionPreset": "beat_pulse", + "preferredTransitionPreset": "snap_cut", + "notes": [ + "低阈值画面变化 42 个,硬切 7 个。", + "真实音频 onset 6 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 3, + "relativeTime": 0.119, + "strength": "strong", + "energyDb": -12.73 + }, + { + "timeSec": 6.5, + "relativeTime": 0.258, + "strength": "medium", + "energyDb": -12.9 + }, + { + "timeSec": 9, + "relativeTime": 0.358, + "strength": "strong", + "energyDb": -12.862 + }, + { + "timeSec": 13, + "relativeTime": 0.517, + "strength": "strong", + "energyDb": -12.58 + }, + { + "timeSec": 16, + "relativeTime": 0.636, + "strength": "strong", + "energyDb": -12.614 + }, + { + "timeSec": 21, + "relativeTime": 0.834, + "strength": "strong", + "energyDb": -12.61 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 1.417, + "relativeTime": 0.056, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 1.75, + "relativeTime": 0.07, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.083, + "relativeTime": 0.083, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.417, + "relativeTime": 0.096, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.95, + "relativeTime": 0.117, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 3, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 3, + "relativeTime": 0.119, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 3.283, + "relativeTime": 0.13, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 3.617, + "relativeTime": 0.144, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.483, + "relativeTime": 0.178, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.817, + "relativeTime": 0.191, + "strength": "medium", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.35, + "relativeTime": 0.213, + "strength": "medium", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.683, + "relativeTime": 0.226, + "strength": "medium", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 6.05, + "relativeTime": 0.24, + "strength": "medium", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 6.5, + "relativeTime": 0.258, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 6.717, + "relativeTime": 0.267, + "strength": "medium", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 7.05, + "relativeTime": 0.28, + "strength": "medium", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 8.35, + "relativeTime": 0.332, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 8.883, + "relativeTime": 0.353, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 9, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 9, + "relativeTime": 0.358, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 13, + "relativeTime": 0.517, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 16, + "relativeTime": 0.636, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 21, + "relativeTime": 0.834, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。", + "renderHints": [ + "full_bleed", + "beat_pulse", + "snap_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "反常全景视角开场 -> 多场景实拍效果展示 -> 产品实体出镜溯源 -> 极简黑屏收尾", + "segmentCount": 4, + "segments": [ + { + "role": "hook", + "label": "反常全景视角开场", + "durationRatio": 0.32, + "intent": "用反常规的鱼眼局部画面第一时间打破观众视觉惯性,避免划走", + "copyPattern": "非常规局部视角+产品标识前置露出", + "watchingPurpose": "开场抓停:用反常规的鱼眼局部画面第一时间打破观众视觉惯性,避免划走" + }, + { + "role": "develop", + "label": "多场景实拍效果展示", + "durationRatio": 0.43, + "intent": "通过不同出行场景的鲜活画面,直观呈现全景相机的畸变特色与全场景适配性", + "copyPattern": "多场景快切+人物互动动作强化画面感染力", + "watchingPurpose": "推进主体:通过不同出行场景的鲜活画面,直观呈现全景相机的畸变特色与全场景适配性" + }, + { + "role": "climax", + "label": "产品实体出镜溯源", + "durationRatio": 0.13, + "intent": "把前面所有场景的拍摄效果和产品本身做关联,完成效果逻辑闭环", + "copyPattern": "开篇场景回溯+手持产品实体露出", + "watchingPurpose": "放大重点:把前面所有场景的拍摄效果和产品本身做关联,完成效果逻辑闭环" + }, + { + "role": "closing", + "label": "极简黑屏收尾", + "durationRatio": 0.12, + "intent": "用无信息的黑屏给观众留出前面所有美好画面的回味空间", + "copyPattern": "纯暗调黑屏无冗余元素收尾", + "watchingPurpose": "收束记忆点:用无信息的黑屏给观众留出前面所有美好画面的回味空间" + } + ], + "notes": [ + "脚本结构由 4 个段落组成,按 hook -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 25.169, + "shotCount": 8, + "avgShotSec": 3.15, + "cutDensity": "medium", + "peakAt": 0.75, + "beatHints": [ + "轻快旅行向鼓点BGM", + "场景切换卡点BGM重音" + ], + "rhythmNotes": [ + "平均 3.1s/镜,整体为 中等节奏。", + "高潮位置约在全片 75%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「左上角悬浮小字产品名称,无大尺寸标题栏」协同", + "animation": "字幕/标题可能配合「场景切换使用快切/闪切,无复杂转场特效」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "左上角悬浮小字产品名称,无大尺寸标题栏", + "stickerUsage": "无额外装饰贴纸,仅保留平台账号标识", + "coverStyle": "鱼眼全景畸变局部人物画面叠加产品名称文字", + "overlayStyle": "左上角悬浮小字产品名称,无大尺寸标题栏 / 无额外装饰贴纸,仅保留平台账号标识", + "notes": [ + "画面包装迁移重点:左上角悬浮小字产品名称,无大尺寸标题栏;无额外装饰贴纸,仅保留平台账号标识;鱼眼全景畸变局部人物画面叠加产品名称文字", + "模板画幅:full_bleed,viewport=720:1280,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "场景切换使用快切/闪切,无复杂转场特效", + "frequency": "中等频率切换", + "notableTransitions": [ + "场景切换使用快切/闪切,无复杂转场特效" + ], + "executableTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "snap_cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻快旅行向鼓点BGM", + "场景切换卡点BGM重音" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "s1", + "segmentRole": "hook", + "requiredAssetTypes": [ + "product_closeup", + "b_roll" + ], + "minDurationSec": 7, + "optional": false + }, + { + "slotId": "s2", + "segmentRole": "develop", + "requiredAssetTypes": [ + "usage_demo", + "b_roll" + ], + "minDurationSec": 10, + "optional": false + }, + { + "slotId": "s3", + "segmentRole": "climax", + "requiredAssetTypes": [ + "talking_head", + "product_closeup" + ], + "minDurationSec": 3, + "optional": false + }, + { + "slotId": "s4", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": true + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 25.169s", + "ref": "seed:v0d00fg10000d7nm3pfog65vfhp87tlg.MP4" + }, + { + "type": "resolution", + "detail": "720x1280 @ 56.534fps" + }, + { + "type": "scene_cut", + "detail": "8 个镜头 / 7 个切点(原始 8 个,已合并 <0.4s 密集检测)", + "ref": "8.12, 10.72, 11.88, 14.75, 18.15, 18.88, 22.15" + }, + { + "type": "template_profile", + "detail": "full_bleed;内部运动 high;模板事件 22 个;音频 onset 6 个", + "ref": "full_bleed, beat_pulse, snap_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 8 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构先用反常规的鱼眼局部画面打破用户刷短视频的视觉惯性,第一时间留住注意力,全程无需冗余台词,通过多旅行场景的鲜活实拍画面直观传递全景相机的独特出片效果,最后用产品实体出镜完成逻辑闭环,收尾黑屏给观众留足对美好出行画面的回味空间,节奏轻快适配短平快的短视频浏览习惯,大幅降低观众的理解成本。", + "createdAt": "2026-06-08T09:48:56.310Z", + "updatedAt": "2026-06-08T09:50:06.342Z" + }, + { + "id": "pattern_604bc007", + "scope": "global", + "sourceSampleId": "a9dc4026-d71f-452c-930a-26a3e8b7e629", + "name": "v0d00fg10000d85bogfog65gqqcqkm5g · 带货模式", + "summary": "该样例是 241s 的 带货 视频,结构为 情绪痛点开场 -> 多角色群像铺垫 -> 情感细节递进+软植入 -> 情感价值锚定 -> slogan记忆点收尾,节奏为 中等节奏。", + "videoGenre": "product", + "tags": [ + "product", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "story_candidate", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "带货:情绪痛点开场 -> slogan记忆点收尾", + "formula": "情绪痛点开场 -> 多角色群像铺垫 -> 情感细节递进+软植入 -> 情感价值锚定 -> slogan记忆点收尾", + "source": { + "filename": "v0d00fg10000d85bogfog65gqqcqkm5g.MP4", + "durationSec": 241.227, + "aspectRatio": "960:720", + "shotCount": 68 + }, + "segments": [ + { + "role": "hook", + "label": "情绪痛点开场", + "durationRatio": 0.1, + "intent": "快速戳中亲密关系中嘴硬逞强不肯低头的大众共鸣点,第一时间抓住观众注意力", + "copyPattern": "核心情绪痛点前置抛出", + "watchingPurpose": "开场抓停:快速戳中亲密关系中嘴硬逞强不肯低头的大众共鸣点,第一时间抓住观众注意力" + }, + { + "role": "setup", + "label": "多角色群像铺垫", + "durationRatio": 0.2, + "intent": "铺陈不同身份人物在亲密关系里的共性逞强状态,扩大受众代入范围", + "copyPattern": "多人物平行叙事强化共性认知", + "watchingPurpose": "建立背景:铺陈不同身份人物在亲密关系里的共性逞强状态,扩大受众代入范围" + }, + { + "role": "develop", + "label": "情感细节递进+软植入", + "durationRatio": 0.4, + "intent": "逐步深化情绪细节,自然穿插产品露出,避免硬广违和感", + "copyPattern": "情感叙事流中穿插产品软植入", + "watchingPurpose": "推进主体:逐步深化情绪细节,自然穿插产品露出,避免硬广违和感" + }, + { + "role": "climax", + "label": "情感价值锚定", + "durationRatio": 0.2, + "intent": "将观众积累的情绪共鸣和产品核心品牌理念深度绑定,完成价值传递", + "copyPattern": "情绪落点对接品牌主张", + "watchingPurpose": "放大重点:将观众积累的情绪共鸣和产品核心品牌理念深度绑定,完成价值传递" + }, + { + "role": "closing", + "label": "slogan记忆点收尾", + "durationRatio": 0.1, + "intent": "清晰露出品牌核心slogan,强化观众品牌记忆", + "copyPattern": "核心品牌信息全屏露出收尾", + "watchingPurpose": "收束记忆点:清晰露出品牌核心slogan,强化观众品牌记忆" + } + ], + "pacing": { + "durationSec": 241.227, + "shotCount": 68, + "avgShotSec": 3.55, + "cutDensity": "medium", + "peakAt": 0.85, + "beatHints": [ + "舒缓抒情铺垫鼓点", + "情绪递进升调", + "重音落点对齐slogan展示节点" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "左上角常驻品牌标识+账号信息", + "stickerUsage": "无多余装饰贴纸,仅保留未成年人饮酒合规提示文字", + "transitionStyle": "以硬切为主,少量关键节点使用淡入淡出过渡", + "coverStyle": "人物情绪特写搭配品牌logo露出" + }, + "storySkeleton": { + "arcType": "带货 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "快速戳中亲密关系中嘴硬逞强不肯低头的大众共鸣点,第一时间抓住观众注意力", + "铺陈不同身份人物在亲密关系里的共性逞强状态,扩大受众代入范围", + "逐步深化情绪细节,自然穿插产品露出,避免硬广违和感", + "将观众积累的情绪共鸣和产品核心品牌理念深度绑定,完成价值传递", + "清晰露出品牌核心slogan,强化观众品牌记忆" + ], + "hookStyle": "核心情绪痛点前置抛出", + "turnOrProofStyle": "情感叙事流中穿插产品软植入", + "payoffStyle": "核心品牌信息全屏露出收尾", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "product" + ], + "assetRequirements": [ + "talking_head", + "b_roll", + "product_closeup", + "usage_demo", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_crossfade", + "name": "可执行转场:crossfade", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "crossfade", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=crossfade,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunction": [ + "opening_hook", + "transition", + "cta" + ], + "appliesToAssetType": [ + "text" + ], + "appliesToVisualCluster": [], + "beatPlacement": "phrase boundary or shot boundary", + "cardAnimationPreset": "soft_crossfade", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "remotion", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ], + "packagingPattern": { + "titleBarStyle": "左上角常驻品牌标识+账号信息", + "stickerUsage": "无多余装饰贴纸,仅保留未成年人饮酒合规提示文字", + "coverStyle": "人物情绪特写搭配品牌logo露出", + "overlayStyle": "左上角常驻品牌标识+账号信息 / 无多余装饰贴纸,仅保留未成年人饮酒合规提示文字", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 241.227, + "bpm": 112, + "beatCount": 450, + "beatStability": 0, + "onsetDensity": 0.2818921596670356, + "energyShape": "late_peak", + "peakAt": 0.85, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571, + 100.714, + 102.857, + 105, + 107.143, + 109.286, + 111.429, + 113.571, + 115.714, + 117.857, + 120, + 122.143, + 124.286, + 126.429, + 128.571, + 130.714, + 132.857, + 135, + 137.143, + 139.286, + 141.429, + 143.571, + 145.714, + 147.857, + 150, + 152.143, + 154.286, + 156.429, + 158.571, + 160.714, + 162.857, + 165, + 167.143, + 169.286, + 171.429, + 173.571, + 175.714, + 177.857, + 180, + 182.143, + 184.286, + 186.429, + 188.571, + 190.714, + 192.857, + 195, + 197.143, + 199.286, + 201.429, + 203.571, + 205.714, + 207.857, + 210, + 212.143, + 214.286, + 216.429, + 218.571, + 220.714, + 222.857, + 225, + 227.143, + 229.286, + 231.429, + 233.571, + 235.714, + 237.857, + 240 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714, + 30, + 34.286, + 38.571, + 42.857, + 47.143, + 51.429, + 55.714, + 60, + 64.286, + 68.571, + 72.857, + 77.143, + 81.429, + 85.714, + 90, + 94.286, + 98.571, + 102.857, + 107.143, + 111.429, + 115.714, + 120, + 124.286, + 128.571, + 132.857, + 137.143, + 141.429, + 145.714, + 150, + 154.286, + 158.571, + 162.857, + 167.143, + 171.429, + 175.714, + 180, + 184.286, + 188.571, + 192.857, + 197.143, + 201.429, + 205.714, + 210, + 214.286, + 218.571, + 222.857, + 227.143, + 231.429, + 235.714, + 240 + ], + "sections": [ + { + "startSec": 0, + "endSec": 43.421, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857 + ] + }, + { + "startSec": 43.421, + "endSec": 164.034, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571, + 100.714, + 102.857, + 105, + 107.143, + 109.286, + 111.429, + 113.571, + 115.714, + 117.857, + 120, + 122.143, + 124.286, + 126.429, + 128.571, + 130.714, + 132.857, + 135, + 137.143, + 139.286, + 141.429, + 143.571, + 145.714, + 147.857, + 150, + 152.143, + 154.286, + 156.429, + 158.571, + 160.714, + 162.857 + ] + }, + { + "startSec": 164.034, + "endSec": 207.455, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 165, + 167.143, + 169.286, + 171.429, + 173.571, + 175.714, + 177.857, + 180, + 182.143, + 184.286, + 186.429, + 188.571, + 190.714, + 192.857, + 195, + 197.143, + 199.286, + 201.429, + 203.571, + 205.714 + ] + }, + { + "startSec": 207.455, + "endSec": 241.227, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 207.857, + 210, + 212.143, + 214.286, + 216.429, + 218.571, + 220.714, + 222.857, + 225, + 227.143, + 229.286, + 231.429, + 233.571, + 235.714, + 237.857, + 240 + ] + } + ], + "tags": [ + "product", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_a9dc4026-d71f-452c-930a-26a3e8b7e629", + "source": "global_sample", + "durationSec": 241.227, + "music": { + "hasAudio": true, + "durationSec": 241.227, + "bpm": 112, + "beatCount": 450, + "beatStability": 0, + "onsetDensity": 0.2818921596670356, + "energyShape": "late_peak", + "peakAt": 0.85, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571, + 100.714, + 102.857, + 105, + 107.143, + 109.286, + 111.429, + 113.571, + 115.714, + 117.857, + 120, + 122.143, + 124.286, + 126.429, + 128.571, + 130.714, + 132.857, + 135, + 137.143, + 139.286, + 141.429, + 143.571, + 145.714, + 147.857, + 150, + 152.143, + 154.286, + 156.429, + 158.571, + 160.714, + 162.857, + 165, + 167.143, + 169.286, + 171.429, + 173.571, + 175.714, + 177.857, + 180, + 182.143, + 184.286, + 186.429, + 188.571, + 190.714, + 192.857, + 195, + 197.143, + 199.286, + 201.429, + 203.571, + 205.714, + 207.857, + 210, + 212.143, + 214.286, + 216.429, + 218.571, + 220.714, + 222.857, + 225, + 227.143, + 229.286, + 231.429, + 233.571, + 235.714, + 237.857, + 240 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714, + 30, + 34.286, + 38.571, + 42.857, + 47.143, + 51.429, + 55.714, + 60, + 64.286, + 68.571, + 72.857, + 77.143, + 81.429, + 85.714, + 90, + 94.286, + 98.571, + 102.857, + 107.143, + 111.429, + 115.714, + 120, + 124.286, + 128.571, + 132.857, + 137.143, + 141.429, + 145.714, + 150, + 154.286, + 158.571, + 162.857, + 167.143, + 171.429, + 175.714, + 180, + 184.286, + 188.571, + 192.857, + 197.143, + 201.429, + 205.714, + 210, + 214.286, + 218.571, + 222.857, + 227.143, + 231.429, + 235.714, + 240 + ], + "sections": [ + { + "startSec": 0, + "endSec": 43.421, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857 + ] + }, + { + "startSec": 43.421, + "endSec": 164.034, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571, + 100.714, + 102.857, + 105, + 107.143, + 109.286, + 111.429, + 113.571, + 115.714, + 117.857, + 120, + 122.143, + 124.286, + 126.429, + 128.571, + 130.714, + 132.857, + 135, + 137.143, + 139.286, + 141.429, + 143.571, + 145.714, + 147.857, + 150, + 152.143, + 154.286, + 156.429, + 158.571, + 160.714, + 162.857 + ] + }, + { + "startSec": 164.034, + "endSec": 207.455, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 165, + 167.143, + 169.286, + 171.429, + 173.571, + 175.714, + 177.857, + 180, + 182.143, + 184.286, + 186.429, + 188.571, + 190.714, + 192.857, + 195, + 197.143, + 199.286, + 201.429, + 203.571, + 205.714 + ] + }, + { + "startSec": 207.455, + "endSec": 241.227, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 207.857, + 210, + 212.143, + 214.286, + 216.429, + 218.571, + 220.714, + 222.857, + 225, + 227.143, + 229.286, + 231.429, + 233.571, + 235.714, + 237.857, + 240 + ] + } + ], + "tags": [ + "product", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 68, + "avgShotSec": 3.55, + "peakAt": 0.85, + "cutEveryBeats": 4.032, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "情绪痛点开场" + }, + { + "eventType": "cut", + "timeSec": 0.04, + "relativeTime": 0.00016581891745119743, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 40, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 2.32, + "relativeTime": 0.00961749721216945, + "beatIndex": 4, + "phraseIndex": 0, + "nearestBeatSec": 2.143, + "offsetMs": 177, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 3.4, + "relativeTime": 0.01409460798335178, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 3.214, + "offsetMs": 186, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 4.08, + "relativeTime": 0.016913529580022138, + "beatIndex": 8, + "phraseIndex": 1, + "nearestBeatSec": 4.286, + "offsetMs": -206, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 9.72, + "relativeTime": 0.04029399694064097, + "beatIndex": 18, + "phraseIndex": 2, + "nearestBeatSec": 9.643, + "offsetMs": 77, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.8, + "relativeTime": 0.04891658064810324, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 11.786, + "offsetMs": 14, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 13.84, + "relativeTime": 0.05737334543811431, + "beatIndex": 26, + "phraseIndex": 3, + "nearestBeatSec": 13.929, + "offsetMs": -89, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 17.28, + "relativeTime": 0.0716337723389173, + "beatIndex": 32, + "phraseIndex": 4, + "nearestBeatSec": 17.143, + "offsetMs": 137, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 18.56, + "relativeTime": 0.0769399776973556, + "beatIndex": 35, + "phraseIndex": 4, + "nearestBeatSec": 18.75, + "offsetMs": -190, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 19.2, + "relativeTime": 0.07959308037657475, + "beatIndex": 36, + "phraseIndex": 4, + "nearestBeatSec": 19.286, + "offsetMs": -86, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 19.8, + "relativeTime": 0.08208036413834273, + "beatIndex": 37, + "phraseIndex": 4, + "nearestBeatSec": 19.821, + "offsetMs": -21, + "strength": "medium", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 20.28, + "relativeTime": 0.0840701911477571, + "beatIndex": 38, + "phraseIndex": 4, + "nearestBeatSec": 20.357, + "offsetMs": -77, + "strength": "medium", + "description": "样例第 12 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 20.84, + "relativeTime": 0.08639165599207385, + "beatIndex": 39, + "phraseIndex": 4, + "nearestBeatSec": 20.893, + "offsetMs": -53, + "strength": "medium", + "description": "样例第 13 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 24.123, + "relativeTime": 0.10000124364188089, + "beatIndex": 45, + "phraseIndex": 5, + "nearestBeatSec": 24.107, + "offsetMs": 16, + "segmentRole": "setup", + "strength": "medium", + "description": "多角色群像铺垫" + }, + { + "eventType": "cut", + "timeSec": 26.04, + "relativeTime": 0.10794811526072952, + "beatIndex": 49, + "phraseIndex": 6, + "nearestBeatSec": 26.25, + "offsetMs": -210, + "strength": "medium", + "description": "样例第 14 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 31.48, + "relativeTime": 0.13049948803409236, + "beatIndex": 59, + "phraseIndex": 7, + "nearestBeatSec": 31.607, + "offsetMs": -127, + "strength": "medium", + "description": "样例第 15 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 33.2, + "relativeTime": 0.13762970148449385, + "beatIndex": 62, + "phraseIndex": 7, + "nearestBeatSec": 33.214, + "offsetMs": -14, + "strength": "medium", + "description": "样例第 16 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 36.16, + "relativeTime": 0.14990030137588245, + "beatIndex": 67, + "phraseIndex": 8, + "nearestBeatSec": 35.893, + "offsetMs": 267, + "strength": "medium", + "description": "样例第 17 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 47.28, + "relativeTime": 0.19599796042731535, + "beatIndex": 88, + "phraseIndex": 11, + "nearestBeatSec": 47.143, + "offsetMs": 137, + "strength": "medium", + "description": "样例第 18 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 49, + "relativeTime": 0.20312817387771684, + "beatIndex": 91, + "phraseIndex": 11, + "nearestBeatSec": 48.75, + "offsetMs": 250, + "strength": "medium", + "description": "样例第 19 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 51.44, + "relativeTime": 0.21324312784223987, + "beatIndex": 96, + "phraseIndex": 12, + "nearestBeatSec": 51.429, + "offsetMs": 11, + "strength": "medium", + "description": "样例第 20 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 53.04, + "relativeTime": 0.21987588454028778, + "beatIndex": 99, + "phraseIndex": 12, + "nearestBeatSec": 53.036, + "offsetMs": 4, + "strength": "medium", + "description": "样例第 21 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 56.76, + "relativeTime": 0.23529704386324912, + "beatIndex": 106, + "phraseIndex": 13, + "nearestBeatSec": 56.786, + "offsetMs": -26, + "strength": "medium", + "description": "样例第 22 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 58.72, + "relativeTime": 0.2434221708183578, + "beatIndex": 110, + "phraseIndex": 13, + "nearestBeatSec": 58.929, + "offsetMs": -209, + "strength": "medium", + "description": "样例第 23 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 64.48, + "relativeTime": 0.2673000949313303, + "beatIndex": 120, + "phraseIndex": 15, + "nearestBeatSec": 64.286, + "offsetMs": 194, + "strength": "medium", + "description": "样例第 24 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 72.368, + "relativeTime": 0.29999958545270633, + "beatIndex": 135, + "phraseIndex": 16, + "nearestBeatSec": 72.321, + "offsetMs": 47, + "segmentRole": "develop", + "strength": "medium", + "description": "情感细节递进+软植入" + }, + { + "eventType": "cut", + "timeSec": 78.24, + "relativeTime": 0.32434180253454215, + "beatIndex": 146, + "phraseIndex": 18, + "nearestBeatSec": 78.214, + "offsetMs": 26, + "strength": "medium", + "description": "样例第 25 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 86.72, + "relativeTime": 0.359495413034196, + "beatIndex": 162, + "phraseIndex": 20, + "nearestBeatSec": 86.786, + "offsetMs": -66, + "strength": "medium", + "description": "样例第 26 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 88.24, + "relativeTime": 0.36579653189734146, + "beatIndex": 165, + "phraseIndex": 20, + "nearestBeatSec": 88.393, + "offsetMs": -153, + "strength": "medium", + "description": "样例第 27 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 89.88, + "relativeTime": 0.3725951075128406, + "beatIndex": 168, + "phraseIndex": 21, + "nearestBeatSec": 90, + "offsetMs": -120, + "strength": "medium", + "description": "样例第 28 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 92.04, + "relativeTime": 0.3815493290552053, + "beatIndex": 172, + "phraseIndex": 21, + "nearestBeatSec": 92.143, + "offsetMs": -103, + "strength": "medium", + "description": "样例第 29 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 93.44, + "relativeTime": 0.38735299116599714, + "beatIndex": 174, + "phraseIndex": 21, + "nearestBeatSec": 93.214, + "offsetMs": 226, + "strength": "medium", + "description": "样例第 30 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 95.56, + "relativeTime": 0.3961413937909106, + "beatIndex": 178, + "phraseIndex": 22, + "nearestBeatSec": 95.357, + "offsetMs": 203, + "strength": "medium", + "description": "样例第 31 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 97.2, + "relativeTime": 0.40293996940640975, + "beatIndex": 181, + "phraseIndex": 22, + "nearestBeatSec": 96.964, + "offsetMs": 236, + "strength": "medium", + "description": "样例第 32 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 109.16, + "relativeTime": 0.45251982572431776, + "beatIndex": 204, + "phraseIndex": 25, + "nearestBeatSec": 109.286, + "offsetMs": -126, + "strength": "medium", + "description": "样例第 33 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 115.56, + "relativeTime": 0.47905085251650936, + "beatIndex": 216, + "phraseIndex": 27, + "nearestBeatSec": 115.714, + "offsetMs": -154, + "strength": "medium", + "description": "样例第 34 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 117.24, + "relativeTime": 0.4860152470494596, + "beatIndex": 219, + "phraseIndex": 27, + "nearestBeatSec": 117.321, + "offsetMs": -81, + "strength": "medium", + "description": "样例第 35 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 121.96, + "relativeTime": 0.5055818793087009, + "beatIndex": 228, + "phraseIndex": 28, + "nearestBeatSec": 122.143, + "offsetMs": -183, + "strength": "medium", + "description": "样例第 36 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 125.92, + "relativeTime": 0.5219979521363695, + "beatIndex": 235, + "phraseIndex": 29, + "nearestBeatSec": 125.893, + "offsetMs": 27, + "strength": "medium", + "description": "样例第 37 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 127.56, + "relativeTime": 0.5287965277518686, + "beatIndex": 238, + "phraseIndex": 29, + "nearestBeatSec": 127.5, + "offsetMs": 60, + "strength": "medium", + "description": "样例第 38 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 129.36, + "relativeTime": 0.5362583790371726, + "beatIndex": 241, + "phraseIndex": 30, + "nearestBeatSec": 129.107, + "offsetMs": 253, + "strength": "medium", + "description": "样例第 39 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 131.4, + "relativeTime": 0.5447151438271836, + "beatIndex": 245, + "phraseIndex": 30, + "nearestBeatSec": 131.25, + "offsetMs": 150, + "strength": "medium", + "description": "样例第 40 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 134, + "relativeTime": 0.5554933734615114, + "beatIndex": 250, + "phraseIndex": 31, + "nearestBeatSec": 133.929, + "offsetMs": 71, + "strength": "medium", + "description": "样例第 41 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 148.72, + "relativeTime": 0.616514735083552, + "beatIndex": 278, + "phraseIndex": 34, + "nearestBeatSec": 148.929, + "offsetMs": -209, + "strength": "medium", + "description": "样例第 42 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 151.24, + "relativeTime": 0.6269613268829775, + "beatIndex": 282, + "phraseIndex": 35, + "nearestBeatSec": 151.071, + "offsetMs": 169, + "strength": "medium", + "description": "样例第 43 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 162.48, + "relativeTime": 0.6735564426867638, + "beatIndex": 303, + "phraseIndex": 37, + "nearestBeatSec": 162.321, + "offsetMs": 159, + "strength": "medium", + "description": "样例第 44 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 168.8, + "relativeTime": 0.6997558316440532, + "beatIndex": 315, + "phraseIndex": 39, + "nearestBeatSec": 168.75, + "offsetMs": 50, + "strength": "medium", + "description": "样例第 45 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 168.859, + "relativeTime": 0.7000004145472937, + "beatIndex": 315, + "phraseIndex": 39, + "nearestBeatSec": 168.75, + "offsetMs": 109, + "segmentRole": "climax", + "strength": "medium", + "description": "情感价值锚定" + }, + { + "eventType": "cut", + "timeSec": 180.56, + "relativeTime": 0.7485065933747052, + "beatIndex": 337, + "phraseIndex": 42, + "nearestBeatSec": 180.536, + "offsetMs": 24, + "strength": "medium", + "description": "样例第 46 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 182.52, + "relativeTime": 0.7566317203298139, + "beatIndex": 341, + "phraseIndex": 42, + "nearestBeatSec": 182.679, + "offsetMs": -159, + "strength": "medium", + "description": "样例第 47 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 186.48, + "relativeTime": 0.7730477931574823, + "beatIndex": 348, + "phraseIndex": 43, + "nearestBeatSec": 186.429, + "offsetMs": 51, + "strength": "medium", + "description": "样例第 48 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 189.52, + "relativeTime": 0.7856500308837734, + "beatIndex": 354, + "phraseIndex": 44, + "nearestBeatSec": 189.643, + "offsetMs": -123, + "strength": "medium", + "description": "样例第 49 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 192.04, + "relativeTime": 0.7960966226831988, + "beatIndex": 358, + "phraseIndex": 44, + "nearestBeatSec": 191.786, + "offsetMs": 254, + "strength": "medium", + "description": "样例第 50 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 197.44, + "relativeTime": 0.8184821765391105, + "beatIndex": 369, + "phraseIndex": 46, + "nearestBeatSec": 197.679, + "offsetMs": -239, + "strength": "medium", + "description": "样例第 51 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 199.2, + "relativeTime": 0.8257782089069631, + "beatIndex": 372, + "phraseIndex": 46, + "nearestBeatSec": 199.286, + "offsetMs": -86, + "strength": "medium", + "description": "样例第 52 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 200.12, + "relativeTime": 0.8295920440083407, + "beatIndex": 374, + "phraseIndex": 46, + "nearestBeatSec": 200.357, + "offsetMs": -237, + "strength": "medium", + "description": "样例第 53 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 200.68, + "relativeTime": 0.8319135088526575, + "beatIndex": 375, + "phraseIndex": 46, + "nearestBeatSec": 200.893, + "offsetMs": -213, + "strength": "medium", + "description": "样例第 54 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 204.64, + "relativeTime": 0.8483295816803259, + "beatIndex": 382, + "phraseIndex": 47, + "nearestBeatSec": 204.643, + "offsetMs": -3, + "strength": "medium", + "description": "样例第 55 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 206.12, + "relativeTime": 0.8544648816260203, + "beatIndex": 385, + "phraseIndex": 48, + "nearestBeatSec": 206.25, + "offsetMs": -130, + "strength": "medium", + "description": "样例第 56 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 207.12, + "relativeTime": 0.8586103545623003, + "beatIndex": 387, + "phraseIndex": 48, + "nearestBeatSec": 207.321, + "offsetMs": -201, + "strength": "medium", + "description": "样例第 57 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 208.68, + "relativeTime": 0.865077292342897, + "beatIndex": 390, + "phraseIndex": 48, + "nearestBeatSec": 208.929, + "offsetMs": -249, + "strength": "medium", + "description": "样例第 58 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 211.64, + "relativeTime": 0.8773478922342854, + "beatIndex": 395, + "phraseIndex": 49, + "nearestBeatSec": 211.607, + "offsetMs": 33, + "strength": "medium", + "description": "样例第 59 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 213.28, + "relativeTime": 0.8841464678497847, + "beatIndex": 398, + "phraseIndex": 49, + "nearestBeatSec": 213.214, + "offsetMs": 66, + "strength": "medium", + "description": "样例第 60 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 215.44, + "relativeTime": 0.8931006893921493, + "beatIndex": 402, + "phraseIndex": 50, + "nearestBeatSec": 215.357, + "offsetMs": 83, + "strength": "medium", + "description": "样例第 61 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 217.104, + "relativeTime": 0.8999987563581192, + "beatIndex": 405, + "phraseIndex": 50, + "nearestBeatSec": 216.964, + "offsetMs": 140, + "segmentRole": "closing", + "strength": "medium", + "description": "slogan记忆点收尾" + }, + { + "eventType": "cut", + "timeSec": 218.76, + "relativeTime": 0.9068636595405987, + "beatIndex": 408, + "phraseIndex": 51, + "nearestBeatSec": 218.571, + "offsetMs": 189, + "strength": "medium", + "description": "样例第 62 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 221.44, + "relativeTime": 0.9179735270098289, + "beatIndex": 413, + "phraseIndex": 51, + "nearestBeatSec": 221.25, + "offsetMs": 190, + "strength": "medium", + "description": "样例第 63 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 222.2, + "relativeTime": 0.9211240864414016, + "beatIndex": 415, + "phraseIndex": 51, + "nearestBeatSec": 222.321, + "offsetMs": -121, + "strength": "medium", + "description": "样例第 64 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 223.12, + "relativeTime": 0.9249379215427792, + "beatIndex": 416, + "phraseIndex": 52, + "nearestBeatSec": 222.857, + "offsetMs": 263, + "strength": "medium", + "description": "样例第 65 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 225.4, + "relativeTime": 0.9343895998374975, + "beatIndex": 421, + "phraseIndex": 52, + "nearestBeatSec": 225.536, + "offsetMs": -136, + "strength": "medium", + "description": "样例第 66 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 229.72, + "relativeTime": 0.9522980429222268, + "beatIndex": 429, + "phraseIndex": 53, + "nearestBeatSec": 229.821, + "offsetMs": -101, + "strength": "strong", + "description": "样例第 67 个切镜点" + } + ], + "cutIntervalsSec": [ + 0.04, + 2.28, + 1.08, + 0.68, + 5.64, + 2.08, + 2.04, + 3.44, + 1.28, + 0.64, + 0.6, + 0.48, + 0.56, + 5.2, + 5.44, + 1.72, + 2.96, + 11.12, + 1.72, + 2.44, + 1.6, + 3.72, + 1.96, + 5.76, + 13.76, + 8.48, + 1.52, + 1.64, + 2.16, + 1.4, + 2.12, + 1.64, + 11.96, + 6.4, + 1.68, + 4.72, + 3.96, + 1.64, + 1.8, + 2.04, + 2.6, + 14.72, + 2.52, + 11.24, + 6.32, + 11.76, + 1.96, + 3.96, + 3.04, + 2.52, + 5.4, + 1.76, + 0.92, + 0.56, + 3.96, + 1.48, + 1, + 1.56, + 2.96, + 1.64, + 2.16, + 3.32, + 2.68, + 0.76, + 0.92, + 2.28, + 4.32, + 11.507 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:68 镜,平均 3.5s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_a9dc4026-d71f-452c-930a-26a3e8b7e629", + "source": "global_sample", + "durationSec": 241.227, + "sourceAspect": "960:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "960:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 50 个,硬切 67 个。", + "真实音频 onset 24 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 1.5, + "relativeTime": 0.006, + "strength": "weak", + "energyDb": -17.668 + }, + { + "timeSec": 3, + "relativeTime": 0.012, + "strength": "weak", + "energyDb": -17.661 + }, + { + "timeSec": 4.5, + "relativeTime": 0.019, + "strength": "medium", + "energyDb": -16.89 + }, + { + "timeSec": 16.5, + "relativeTime": 0.068, + "strength": "weak", + "energyDb": -19.332 + }, + { + "timeSec": 21, + "relativeTime": 0.087, + "strength": "medium", + "energyDb": -16.043 + }, + { + "timeSec": 27, + "relativeTime": 0.112, + "strength": "weak", + "energyDb": -19.442 + }, + { + "timeSec": 35, + "relativeTime": 0.145, + "strength": "weak", + "energyDb": -18.587 + }, + { + "timeSec": 43, + "relativeTime": 0.178, + "strength": "strong", + "energyDb": -15.837 + }, + { + "timeSec": 53, + "relativeTime": 0.22, + "strength": "medium", + "energyDb": -16.494 + }, + { + "timeSec": 60.5, + "relativeTime": 0.251, + "strength": "strong", + "energyDb": -15.394 + }, + { + "timeSec": 62.5, + "relativeTime": 0.259, + "strength": "weak", + "energyDb": -18.838 + }, + { + "timeSec": 68, + "relativeTime": 0.282, + "strength": "strong", + "energyDb": -9.934 + }, + { + "timeSec": 69.5, + "relativeTime": 0.288, + "strength": "strong", + "energyDb": -12.831 + }, + { + "timeSec": 70.5, + "relativeTime": 0.292, + "strength": "strong", + "energyDb": -11.059 + }, + { + "timeSec": 72, + "relativeTime": 0.298, + "strength": "strong", + "energyDb": -11.626 + }, + { + "timeSec": 106, + "relativeTime": 0.439, + "strength": "medium", + "energyDb": -15.967 + }, + { + "timeSec": 152.5, + "relativeTime": 0.632, + "strength": "weak", + "energyDb": -19.227 + }, + { + "timeSec": 156, + "relativeTime": 0.647, + "strength": "weak", + "energyDb": -18.496 + }, + { + "timeSec": 164.5, + "relativeTime": 0.682, + "strength": "weak", + "energyDb": -19.257 + }, + { + "timeSec": 168.5, + "relativeTime": 0.699, + "strength": "weak", + "energyDb": -17.616 + }, + { + "timeSec": 173.5, + "relativeTime": 0.719, + "strength": "strong", + "energyDb": -15.202 + }, + { + "timeSec": 176, + "relativeTime": 0.73, + "strength": "strong", + "energyDb": -14.74 + }, + { + "timeSec": 177, + "relativeTime": 0.734, + "strength": "strong", + "energyDb": -15.226 + }, + { + "timeSec": 179.5, + "relativeTime": 0.744, + "strength": "medium", + "energyDb": -16.205 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 1.12, + "relativeTime": 0.005, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 1.5, + "relativeTime": 0.006, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 3, + "relativeTime": 0.012, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 4.5, + "relativeTime": 0.019, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 4.84, + "relativeTime": 0.02, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 16.5, + "relativeTime": 0.068, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 21, + "relativeTime": 0.087, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 21.32, + "relativeTime": 0.088, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 27, + "relativeTime": 0.112, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 29.6, + "relativeTime": 0.123, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 35, + "relativeTime": 0.145, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 38.32, + "relativeTime": 0.159, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 43, + "relativeTime": 0.178, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 44.64, + "relativeTime": 0.185, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 46.04, + "relativeTime": 0.191, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 53, + "relativeTime": 0.22, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 60.44, + "relativeTime": 0.251, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 60.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 60.5, + "relativeTime": 0.251, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 62.5, + "relativeTime": 0.259, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 67.96, + "relativeTime": 0.282, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 68, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 68, + "relativeTime": 0.282, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 68.8, + "relativeTime": 0.285, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 69.48, + "relativeTime": 0.288, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 69.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 70, + "relativeTime": 0.29, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 70.52, + "relativeTime": 0.292, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 70.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 70.88, + "relativeTime": 0.294, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 71.4, + "relativeTime": 0.296, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 71.76, + "relativeTime": 0.297, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "ken_burns_in", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "情绪痛点开场 -> 多角色群像铺垫 -> 情感细节递进+软植入 -> 情感价值锚定 -> slogan记忆点收尾", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "情绪痛点开场", + "durationRatio": 0.1, + "intent": "快速戳中亲密关系中嘴硬逞强不肯低头的大众共鸣点,第一时间抓住观众注意力", + "copyPattern": "核心情绪痛点前置抛出", + "watchingPurpose": "开场抓停:快速戳中亲密关系中嘴硬逞强不肯低头的大众共鸣点,第一时间抓住观众注意力" + }, + { + "role": "setup", + "label": "多角色群像铺垫", + "durationRatio": 0.2, + "intent": "铺陈不同身份人物在亲密关系里的共性逞强状态,扩大受众代入范围", + "copyPattern": "多人物平行叙事强化共性认知", + "watchingPurpose": "建立背景:铺陈不同身份人物在亲密关系里的共性逞强状态,扩大受众代入范围" + }, + { + "role": "develop", + "label": "情感细节递进+软植入", + "durationRatio": 0.4, + "intent": "逐步深化情绪细节,自然穿插产品露出,避免硬广违和感", + "copyPattern": "情感叙事流中穿插产品软植入", + "watchingPurpose": "推进主体:逐步深化情绪细节,自然穿插产品露出,避免硬广违和感" + }, + { + "role": "climax", + "label": "情感价值锚定", + "durationRatio": 0.2, + "intent": "将观众积累的情绪共鸣和产品核心品牌理念深度绑定,完成价值传递", + "copyPattern": "情绪落点对接品牌主张", + "watchingPurpose": "放大重点:将观众积累的情绪共鸣和产品核心品牌理念深度绑定,完成价值传递" + }, + { + "role": "closing", + "label": "slogan记忆点收尾", + "durationRatio": 0.1, + "intent": "清晰露出品牌核心slogan,强化观众品牌记忆", + "copyPattern": "核心品牌信息全屏露出收尾", + "watchingPurpose": "收束记忆点:清晰露出品牌核心slogan,强化观众品牌记忆" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 241.227, + "shotCount": 68, + "avgShotSec": 3.55, + "cutDensity": "medium", + "peakAt": 0.85, + "beatHints": [ + "舒缓抒情铺垫鼓点", + "情绪递进升调", + "重音落点对齐slogan展示节点" + ], + "rhythmNotes": [ + "平均 3.5s/镜,整体为 中等节奏。", + "高潮位置约在全片 85%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「左上角常驻品牌标识+账号信息」协同", + "animation": "字幕/标题可能配合「以硬切为主,少量关键节点使用淡入淡出过渡」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "左上角常驻品牌标识+账号信息", + "stickerUsage": "无多余装饰贴纸,仅保留未成年人饮酒合规提示文字", + "coverStyle": "人物情绪特写搭配品牌logo露出", + "overlayStyle": "左上角常驻品牌标识+账号信息 / 无多余装饰贴纸,仅保留未成年人饮酒合规提示文字", + "notes": [ + "画面包装迁移重点:左上角常驻品牌标识+账号信息;无多余装饰贴纸,仅保留未成年人饮酒合规提示文字;人物情绪特写搭配品牌logo露出", + "模板画幅:letterbox_frame,viewport=960:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "以硬切为主,少量关键节点使用淡入淡出过渡", + "frequency": "中等频率切换", + "notableTransitions": [ + "以硬切为主,少量关键节点使用淡入淡出过渡" + ], + "executableTechniques": [ + { + "id": "tech_transition_crossfade", + "name": "可执行转场:crossfade", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "crossfade", + "implementationNotes": "已映射到 Timeline.transitionPreset=crossfade,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunctions": [ + "opening_hook", + "transition", + "cta" + ], + "requiredRenderer": "remotion", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "舒缓抒情铺垫鼓点", + "情绪递进升调", + "重音落点对齐slogan展示节点" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "talking_head", + "b_roll" + ], + "minDurationSec": 24, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "setup", + "requiredAssetTypes": [ + "talking_head", + "b_roll" + ], + "minDurationSec": 48, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "develop", + "requiredAssetTypes": [ + "product_closeup", + "usage_demo", + "b_roll" + ], + "minDurationSec": 96, + "optional": false + }, + { + "slotId": "slot_004", + "segmentRole": "climax", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 48, + "optional": false + }, + { + "slotId": "slot_005", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 24, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 241.227s", + "ref": "seed:v0d00fg10000d85bogfog65gqqcqkm5g.MP4" + }, + { + "type": "resolution", + "detail": "960x720 @ 25fps" + }, + { + "type": "scene_cut", + "detail": "68 个镜头 / 67 个切点(原始 74 个,已合并 <0.4s 密集检测)", + "ref": "0.04, 2.32, 3.40, 4.08, 9.72, 11.80, 13.84, 17.28, 18.56, 19.20, 19.80, 20.28, 20.84, 26.04, 31.48, 33.20, 36.16, 47.28, 49.00, 51.44, 53.04, 56.76, 58.72, 64.48, 78.24, 86.72, 88.24, 89.88, 92.04, 93.44, 95.56, 97.20, 109.16, 115.56, 117.24, 121.96, 125.92, 127.56, 129.36, 131.40, 134.00, 148.72, 151.24, 162.48, 168.80, 180.56, 182.52, 186.48, 189.52, 192.04, 197.44, 199.20, 200.12, 200.68, 204.64, 206.12, 207.12, 208.68, 211.64, 213.28, 215.44, 218.76, 221.44, 222.20, 223.12, 225.40, 229.72" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 low;模板事件 28 个;音频 onset 24 个", + "ref": "letterbox_frame, ken_burns_in, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构从大众普遍共情的亲密关系痛点开场快速抓注意力,通过多角色群像铺垫扩大受众代入范围,中间用情感叙事流自然植入产品避免硬广反感,高潮部分将情绪价值和品牌理念深度绑定,最后用清晰slogan收尾强化记忆,适配情感向品牌宣传的受众观看习惯,节奏松紧有度,避免长视频带来的观众流失问题。", + "createdAt": "2026-06-08T09:55:10.613Z", + "updatedAt": "2026-06-08T09:57:48.178Z" + }, + { + "id": "pattern_b633d946", + "scope": "global", + "sourceSampleId": "8e91c610-aea4-4d70-84b4-2d2551c236a9", + "name": "v1e00fgi0000d7lkhmfog65no7n8sgpg · 展示模式", + "summary": "该样例是 12s 的 展示 视频,结构为 转场特效效果前置展示 -> 搜索引流操作指引,节奏为 慢节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "low", + "hook", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "not_recommended", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:转场特效效果前置展示 -> 搜索引流操作指引", + "formula": "转场特效效果前置展示 -> 搜索引流操作指引", + "source": { + "filename": "v1e00fgi0000d7lkhmfog65no7n8sgpg.MP4", + "durationSec": 12.166, + "aspectRatio": "1280:720", + "shotCount": 2 + }, + "segments": [ + { + "role": "hook", + "label": "转场特效效果前置展示", + "durationRatio": 0.75, + "intent": "用带动态模糊的氛围感行走画面直观呈现转场特效效果,第一时间抓住对特效感兴趣的用户注意力", + "copyPattern": "特效效果无铺垫直接前置展示,搭配弱提示文字预埋搜索线索", + "watchingPurpose": "开场抓停:用带动态模糊的氛围感行走画面直观呈现转场特效效果,第一时间抓住对特效感兴趣的用户注意力" + }, + { + "role": "closing", + "label": "搜索引流操作指引", + "durationRatio": 0.25, + "intent": "清晰告知用户获取对应特效内容的操作路径,完成平台内引流转化", + "copyPattern": "分步操作路径明确告知,搭配扫码/搜索双路径降低用户操作门槛", + "watchingPurpose": "收束记忆点:清晰告知用户获取对应特效内容的操作路径,完成平台内引流转化" + } + ], + "pacing": { + "durationSec": 12.166, + "shotCount": 2, + "avgShotSec": 6.08, + "cutDensity": "low", + "peakAt": 0.75, + "beatHints": [ + "氛围感弱节奏BGM铺垫", + "卡点切引导界面短促提示音" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词", + "stickerUsage": "无额外装饰贴纸,仅保留平台原生品牌标识", + "transitionStyle": "卡点硬切转场,无多余过渡效果", + "coverStyle": "截取带动态模糊效果的户外行走画面,叠加醒目搜索关键词作为封面" + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词", + "stickerUsage": "无额外装饰贴纸,仅保留平台原生品牌标识", + "coverStyle": "截取带动态模糊效果的户外行走画面,叠加醒目搜索关键词作为封面", + "overlayStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词 / 无额外装饰贴纸,仅保留平台原生品牌标识", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 12.166, + "bpm": 92, + "beatCount": 19, + "beatStability": 0.664808743169399, + "onsetDensity": 0.1643925694558606, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.19, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 2.19, + "endSec": 8.273, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609, + 5.217, + 7.826 + ] + }, + { + "startSec": 8.273, + "endSec": 10.463, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 10.435 + ] + }, + { + "startSec": 10.463, + "endSec": 12.166, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_8e91c610-aea4-4d70-84b4-2d2551c236a9", + "source": "global_sample", + "durationSec": 12.166, + "music": { + "hasAudio": true, + "durationSec": 12.166, + "bpm": 92, + "beatCount": 19, + "beatStability": 0.664808743169399, + "onsetDensity": 0.1643925694558606, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.19, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 2.19, + "endSec": 8.273, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609, + 5.217, + 7.826 + ] + }, + { + "startSec": 8.273, + "endSec": 10.463, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 10.435 + ] + }, + { + "startSec": 10.463, + "endSec": 12.166, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 2, + "avgShotSec": 6.08, + "peakAt": 0.75, + "cutEveryBeats": 14.03, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "转场特效效果前置展示" + }, + { + "eventType": "caption", + "timeSec": 9.125, + "relativeTime": 0.750041098142364, + "beatIndex": 14, + "phraseIndex": 1, + "nearestBeatSec": 9.13, + "offsetMs": -5, + "segmentRole": "closing", + "strength": "medium", + "description": "搜索引流操作指引" + }, + { + "eventType": "cut", + "timeSec": 9.15, + "relativeTime": 0.7520960052605622, + "beatIndex": 14, + "phraseIndex": 1, + "nearestBeatSec": 9.13, + "offsetMs": 20, + "strength": "strong", + "description": "样例第 1 个切镜点" + } + ], + "cutIntervalsSec": [ + 9.15, + 3.016 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:2 镜,平均 6.1s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_8e91c610-aea4-4d70-84b4-2d2551c236a9", + "source": "global_sample", + "durationSec": 12.166, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 10 个,硬切 1 个。", + "真实音频 onset 5 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 1.5, + "relativeTime": 0.123, + "strength": "strong", + "energyDb": -8.154 + }, + { + "timeSec": 2.5, + "relativeTime": 0.205, + "strength": "strong", + "energyDb": -6.92 + }, + { + "timeSec": 4, + "relativeTime": 0.329, + "strength": "strong", + "energyDb": -8.004 + }, + { + "timeSec": 5, + "relativeTime": 0.411, + "strength": "weak", + "energyDb": -8.289 + }, + { + "timeSec": 8.5, + "relativeTime": 0.699, + "strength": "medium", + "energyDb": -8.196 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 0.35, + "relativeTime": 0.029, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 0.717, + "relativeTime": 0.059, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 1.05, + "relativeTime": 0.086, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 1.383, + "relativeTime": 0.114, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 1.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 1.5, + "relativeTime": 0.123, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 1.717, + "relativeTime": 0.141, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.05, + "relativeTime": 0.169, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.433, + "relativeTime": 0.2, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 2.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 2.5, + "relativeTime": 0.205, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 3.267, + "relativeTime": 0.269, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 3.617, + "relativeTime": 0.297, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 4, + "relativeTime": 0.329, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 4.617, + "relativeTime": 0.379, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 5, + "relativeTime": 0.411, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 8.5, + "relativeTime": 0.699, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "reveal_pan", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "转场特效效果前置展示 -> 搜索引流操作指引", + "segmentCount": 2, + "segments": [ + { + "role": "hook", + "label": "转场特效效果前置展示", + "durationRatio": 0.75, + "intent": "用带动态模糊的氛围感行走画面直观呈现转场特效效果,第一时间抓住对特效感兴趣的用户注意力", + "copyPattern": "特效效果无铺垫直接前置展示,搭配弱提示文字预埋搜索线索", + "watchingPurpose": "开场抓停:用带动态模糊的氛围感行走画面直观呈现转场特效效果,第一时间抓住对特效感兴趣的用户注意力" + }, + { + "role": "closing", + "label": "搜索引流操作指引", + "durationRatio": 0.25, + "intent": "清晰告知用户获取对应特效内容的操作路径,完成平台内引流转化", + "copyPattern": "分步操作路径明确告知,搭配扫码/搜索双路径降低用户操作门槛", + "watchingPurpose": "收束记忆点:清晰告知用户获取对应特效内容的操作路径,完成平台内引流转化" + } + ], + "notes": [ + "脚本结构由 2 个段落组成,按 hook -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 12.166, + "shotCount": 2, + "avgShotSec": 6.08, + "cutDensity": "low", + "peakAt": 0.75, + "beatHints": [ + "氛围感弱节奏BGM铺垫", + "卡点切引导界面短促提示音" + ], + "rhythmNotes": [ + "平均 6.1s/镜,整体为 慢节奏。", + "高潮位置约在全片 75%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词」协同", + "animation": "字幕/标题可能配合「卡点硬切转场,无多余过渡效果」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词", + "stickerUsage": "无额外装饰贴纸,仅保留平台原生品牌标识", + "coverStyle": "截取带动态模糊效果的户外行走画面,叠加醒目搜索关键词作为封面", + "overlayStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词 / 无额外装饰贴纸,仅保留平台原生品牌标识", + "notes": [ + "画面包装迁移重点:顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词;无额外装饰贴纸,仅保留平台原生品牌标识;截取带动态模糊效果的户外行走画面,叠加醒目搜索关键词作为封面", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "卡点硬切转场,无多余过渡效果", + "frequency": "低频切换", + "notableTransitions": [ + "卡点硬切转场,无多余过渡效果" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "氛围感弱节奏BGM铺垫", + "卡点切引导界面短促提示音" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 7, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 12.166s", + "ref": "seed:v1e00fgi0000d7lkhmfog65no7n8sgpg.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 52.81fps" + }, + { + "type": "scene_cut", + "detail": "2 个镜头 / 1 个切点(原始 1 个,已合并 <0.4s 密集检测)", + "ref": "9.15" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 medium;模板事件 15 个;音频 onset 5 个", + "ref": "letterbox_frame, reveal_pan, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 2 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构完全适配短平快的特效展示类短视频逻辑,开篇直接抛出特效效果无需冗余铺垫,快速筛选并抓住目标受众注意力,后续直接跳转清晰的操作指引界面,全程无无效信息,大幅降低用户理解成本,高效完成引流转化,符合竖屏短视频用户的碎片化观看习惯。", + "createdAt": "2026-06-08T09:55:51.459Z", + "updatedAt": "2026-06-08T09:57:51.678Z" + }, + { + "id": "pattern_fd43c4bf", + "scope": "global", + "sourceSampleId": "8e91c610-aea4-4d70-84b4-2d2551c236a9", + "name": "v1e00fgi0000d7lkhmfog65no7n8sgpg · 展示模式", + "summary": "该样例是 12s 的 展示 视频,结构为 转场特效效果前置展示 -> 搜索引流操作指引,节奏为 慢节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "low", + "hook", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "not_recommended", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": false, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:转场特效效果前置展示 -> 搜索引流操作指引", + "formula": "转场特效效果前置展示 -> 搜索引流操作指引", + "source": { + "filename": "v1e00fgi0000d7lkhmfog65no7n8sgpg.MP4", + "durationSec": 12.166, + "aspectRatio": "1280:720", + "shotCount": 2 + }, + "segments": [ + { + "role": "hook", + "label": "转场特效效果前置展示", + "durationRatio": 0.75, + "intent": "用带动态模糊的氛围感行走画面直观呈现转场特效效果,第一时间抓住对特效感兴趣的用户注意力", + "copyPattern": "特效效果无铺垫直接前置展示,搭配弱提示文字预埋搜索线索", + "watchingPurpose": "开场抓停:用带动态模糊的氛围感行走画面直观呈现转场特效效果,第一时间抓住对特效感兴趣的用户注意力" + }, + { + "role": "closing", + "label": "搜索引流操作指引", + "durationRatio": 0.25, + "intent": "清晰告知用户获取对应特效内容的操作路径,完成平台内引流转化", + "copyPattern": "分步操作路径明确告知,搭配扫码/搜索双路径降低用户操作门槛", + "watchingPurpose": "收束记忆点:清晰告知用户获取对应特效内容的操作路径,完成平台内引流转化" + } + ], + "pacing": { + "durationSec": 12.166, + "shotCount": 2, + "avgShotSec": 6.08, + "cutDensity": "low", + "peakAt": 0.75, + "beatHints": [ + "氛围感弱节奏BGM铺垫", + "卡点切引导界面短促提示音" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词", + "stickerUsage": "无额外装饰贴纸,仅保留平台原生品牌标识", + "transitionStyle": "卡点硬切转场,无多余过渡效果", + "coverStyle": "截取带动态模糊效果的户外行走画面,叠加醒目搜索关键词作为封面" + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词", + "stickerUsage": "无额外装饰贴纸,仅保留平台原生品牌标识", + "coverStyle": "截取带动态模糊效果的户外行走画面,叠加醒目搜索关键词作为封面", + "overlayStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词 / 无额外装饰贴纸,仅保留平台原生品牌标识", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 12.166, + "bpm": 92, + "beatCount": 19, + "beatStability": 0.664808743169399, + "onsetDensity": 0.1643925694558606, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.19, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 2.19, + "endSec": 8.273, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609, + 5.217, + 7.826 + ] + }, + { + "startSec": 8.273, + "endSec": 10.463, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 10.435 + ] + }, + { + "startSec": 10.463, + "endSec": 12.166, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_8e91c610-aea4-4d70-84b4-2d2551c236a9", + "source": "global_sample", + "durationSec": 12.166, + "music": { + "hasAudio": true, + "durationSec": 12.166, + "bpm": 92, + "beatCount": 19, + "beatStability": 0.664808743169399, + "onsetDensity": 0.1643925694558606, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.19, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 2.19, + "endSec": 8.273, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609, + 5.217, + 7.826 + ] + }, + { + "startSec": 8.273, + "endSec": 10.463, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 10.435 + ] + }, + { + "startSec": 10.463, + "endSec": 12.166, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 2, + "avgShotSec": 6.08, + "peakAt": 0.75, + "cutEveryBeats": 14.03, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "转场特效效果前置展示" + }, + { + "eventType": "caption", + "timeSec": 9.125, + "relativeTime": 0.750041098142364, + "beatIndex": 14, + "phraseIndex": 1, + "nearestBeatSec": 9.13, + "offsetMs": -5, + "segmentRole": "closing", + "strength": "medium", + "description": "搜索引流操作指引" + }, + { + "eventType": "cut", + "timeSec": 9.15, + "relativeTime": 0.7520960052605622, + "beatIndex": 14, + "phraseIndex": 1, + "nearestBeatSec": 9.13, + "offsetMs": 20, + "strength": "strong", + "description": "样例第 1 个切镜点" + } + ], + "cutIntervalsSec": [ + 9.15, + 3.016 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:2 镜,平均 6.1s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_8e91c610-aea4-4d70-84b4-2d2551c236a9", + "source": "global_sample", + "durationSec": 12.166, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 10 个,硬切 1 个。", + "真实音频 onset 5 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 1.5, + "relativeTime": 0.123, + "strength": "strong", + "energyDb": -8.154 + }, + { + "timeSec": 2.5, + "relativeTime": 0.205, + "strength": "strong", + "energyDb": -6.92 + }, + { + "timeSec": 4, + "relativeTime": 0.329, + "strength": "strong", + "energyDb": -8.004 + }, + { + "timeSec": 5, + "relativeTime": 0.411, + "strength": "weak", + "energyDb": -8.289 + }, + { + "timeSec": 8.5, + "relativeTime": 0.699, + "strength": "medium", + "energyDb": -8.196 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 0.35, + "relativeTime": 0.029, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 0.717, + "relativeTime": 0.059, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 1.05, + "relativeTime": 0.086, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 1.383, + "relativeTime": 0.114, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 1.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 1.5, + "relativeTime": 0.123, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 1.717, + "relativeTime": 0.141, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.05, + "relativeTime": 0.169, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.433, + "relativeTime": 0.2, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 2.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 2.5, + "relativeTime": 0.205, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 3.267, + "relativeTime": 0.269, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 3.617, + "relativeTime": 0.297, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 4, + "relativeTime": 0.329, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 4.617, + "relativeTime": 0.379, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 5, + "relativeTime": 0.411, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 8.5, + "relativeTime": 0.699, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "reveal_pan", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "转场特效效果前置展示 -> 搜索引流操作指引", + "segmentCount": 2, + "segments": [ + { + "role": "hook", + "label": "转场特效效果前置展示", + "durationRatio": 0.75, + "intent": "用带动态模糊的氛围感行走画面直观呈现转场特效效果,第一时间抓住对特效感兴趣的用户注意力", + "copyPattern": "特效效果无铺垫直接前置展示,搭配弱提示文字预埋搜索线索", + "watchingPurpose": "开场抓停:用带动态模糊的氛围感行走画面直观呈现转场特效效果,第一时间抓住对特效感兴趣的用户注意力" + }, + { + "role": "closing", + "label": "搜索引流操作指引", + "durationRatio": 0.25, + "intent": "清晰告知用户获取对应特效内容的操作路径,完成平台内引流转化", + "copyPattern": "分步操作路径明确告知,搭配扫码/搜索双路径降低用户操作门槛", + "watchingPurpose": "收束记忆点:清晰告知用户获取对应特效内容的操作路径,完成平台内引流转化" + } + ], + "notes": [ + "脚本结构由 2 个段落组成,按 hook -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 12.166, + "shotCount": 2, + "avgShotSec": 6.08, + "cutDensity": "low", + "peakAt": 0.75, + "beatHints": [ + "氛围感弱节奏BGM铺垫", + "卡点切引导界面短促提示音" + ], + "rhythmNotes": [ + "平均 6.1s/镜,整体为 慢节奏。", + "高潮位置约在全片 75%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词」协同", + "animation": "字幕/标题可能配合「卡点硬切转场,无多余过渡效果」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词", + "stickerUsage": "无额外装饰贴纸,仅保留平台原生品牌标识", + "coverStyle": "截取带动态模糊效果的户外行走画面,叠加醒目搜索关键词作为封面", + "overlayStyle": "顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词 / 无额外装饰贴纸,仅保留平台原生品牌标识", + "notes": [ + "画面包装迁移重点:顶部无全屏标题栏,仅悬浮小字展示账号ID与预埋搜索关键词;无额外装饰贴纸,仅保留平台原生品牌标识;截取带动态模糊效果的户外行走画面,叠加醒目搜索关键词作为封面", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "卡点硬切转场,无多余过渡效果", + "frequency": "低频切换", + "notableTransitions": [ + "卡点硬切转场,无多余过渡效果" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "氛围感弱节奏BGM铺垫", + "卡点切引导界面短促提示音" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 7, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 12.166s", + "ref": "seed:v1e00fgi0000d7lkhmfog65no7n8sgpg.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 52.81fps" + }, + { + "type": "scene_cut", + "detail": "2 个镜头 / 1 个切点(原始 1 个,已合并 <0.4s 密集检测)", + "ref": "9.15" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 medium;模板事件 15 个;音频 onset 5 个", + "ref": "letterbox_frame, reveal_pan, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 2 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构完全适配短平快的特效展示类短视频逻辑,开篇直接抛出特效效果无需冗余铺垫,快速筛选并抓住目标受众注意力,后续直接跳转清晰的操作指引界面,全程无无效信息,大幅降低用户理解成本,高效完成引流转化,符合竖屏短视频用户的碎片化观看习惯。", + "createdAt": "2026-06-08T09:57:53.311Z", + "updatedAt": "2026-06-08T09:57:54.663Z" + }, + { + "id": "pattern_9c9040d3", + "scope": "global", + "sourceSampleId": "b4bdec12-c79b-4ee0-a652-b29cfb3fda75", + "name": "v1e00fgi0000d28bkm7og65vgt0lb2m0 · 展示模式", + "summary": "该样例是 28s 的 展示 视频,结构为 品牌信息初露 -> 基础视觉铺垫 -> 动态效果启动 -> 品牌标识全幅延展 -> 品牌视觉收尾,节奏为 慢节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "low", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "not_recommended", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": false, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:品牌信息初露 -> 品牌视觉收尾", + "formula": "品牌信息初露 -> 基础视觉铺垫 -> 动态效果启动 -> 品牌标识全幅延展 -> 品牌视觉收尾", + "source": { + "filename": "v1e00fgi0000d28bkm7og65vgt0lb2m0.MP4", + "durationSec": 27.841, + "aspectRatio": "1280:720", + "shotCount": 2 + }, + "segments": [ + { + "role": "hook", + "label": "品牌信息初露", + "durationRatio": 0.05, + "intent": "第一时间同步传递品牌基础标识与发布账号信息,快速筛选对品牌感兴趣的目标受众", + "copyPattern": "静态品牌文字+账号信息同步直给", + "watchingPurpose": "开场抓停:第一时间同步传递品牌基础标识与发布账号信息,快速筛选对品牌感兴趣的目标受众" + }, + { + "role": "setup", + "label": "基础视觉铺垫", + "durationRatio": 0.15, + "intent": "让观众清晰识别品牌文字与小标识的标准组合形态,建立初始品牌认知", + "copyPattern": "固定帧展示品牌官方标准视觉组合", + "watchingPurpose": "建立背景:让观众清晰识别品牌文字与小标识的标准组合形态,建立初始品牌认知" + }, + { + "role": "develop", + "label": "动态效果启动", + "durationRatio": 0.3, + "intent": "引导观众注意力向核心品牌图形转移,铺垫动态延展的期待感", + "copyPattern": "品牌核心图形逐步放大入场", + "watchingPurpose": "推进主体:引导观众注意力向核心品牌图形转移,铺垫动态延展的期待感" + }, + { + "role": "climax", + "label": "品牌标识全幅延展", + "durationRatio": 0.4, + "intent": "最大化呈现品牌视觉的冲击力,深度强化品牌记忆点", + "copyPattern": "大尺寸渐变品牌图形全屏展示,拉满视觉张力", + "watchingPurpose": "放大重点:最大化呈现品牌视觉的冲击力,深度强化品牌记忆点" + }, + { + "role": "closing", + "label": "品牌视觉收尾", + "durationRatio": 0.1, + "intent": "完成品牌展示闭环,留给观众足够的视觉记忆停留时间", + "copyPattern": "最终定格全幅品牌标识画面", + "watchingPurpose": "收束记忆点:完成品牌展示闭环,留给观众足够的视觉记忆停留时间" + } + ], + "pacing": { + "durationSec": 27.841, + "shotCount": 2, + "avgShotSec": 13.92, + "cutDensity": "low", + "peakAt": 0.6, + "beatHints": [ + "低缓氛围感电子音铺垫", + "高潮部分音浪同步抬升匹配图形延展节奏" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "无主标题,仅左上角固定账号信息条", + "stickerUsage": "无额外装饰贴纸,仅保留品牌原生视觉元素", + "transitionStyle": "硬切+渐变转场组合", + "coverStyle": "品牌标识居中展示+渐变背景的极简视觉风格" + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "无主标题,仅左上角固定账号信息条", + "stickerUsage": "无额外装饰贴纸,仅保留品牌原生视觉元素", + "coverStyle": "品牌标识居中展示+渐变背景的极简视觉风格", + "overlayStyle": "无主标题,仅左上角固定账号信息条 / 无额外装饰贴纸,仅保留品牌原生视觉元素", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 27.841, + "bpm": 92, + "beatCount": 43, + "beatStability": 0.509143776745547, + "onsetDensity": 0.07183650012571387, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435, + 13.043, + 15.652, + 18.261, + 20.87, + 23.478, + 26.087 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435, + 15.652, + 20.87, + 26.087 + ], + "sections": [ + { + "startSec": 0, + "endSec": 5.011, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.609 + ] + }, + { + "startSec": 5.011, + "endSec": 15.313, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 5.217, + 7.826, + 10.435, + 13.043 + ] + }, + { + "startSec": 15.313, + "endSec": 20.324, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 15.652, + 18.261 + ] + }, + { + "startSec": 20.324, + "endSec": 27.841, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 20.87, + 23.478, + 26.087 + ] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_b4bdec12-c79b-4ee0-a652-b29cfb3fda75", + "source": "global_sample", + "durationSec": 27.841, + "music": { + "hasAudio": true, + "durationSec": 27.841, + "bpm": 92, + "beatCount": 43, + "beatStability": 0.509143776745547, + "onsetDensity": 0.07183650012571387, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435, + 13.043, + 15.652, + 18.261, + 20.87, + 23.478, + 26.087 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435, + 15.652, + 20.87, + 26.087 + ], + "sections": [ + { + "startSec": 0, + "endSec": 5.011, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.609 + ] + }, + { + "startSec": 5.011, + "endSec": 15.313, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 5.217, + 7.826, + 10.435, + 13.043 + ] + }, + { + "startSec": 15.313, + "endSec": 20.324, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 15.652, + 18.261 + ] + }, + { + "startSec": 20.324, + "endSec": 27.841, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 20.87, + 23.478, + 26.087 + ] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 2, + "avgShotSec": 13.92, + "peakAt": 0.6, + "cutEveryBeats": 41.923, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "品牌信息初露" + }, + { + "eventType": "cut", + "timeSec": 0.5, + "relativeTime": 0.017959125031428467, + "beatIndex": 1, + "phraseIndex": 0, + "nearestBeatSec": 0.652, + "offsetMs": -152, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 1.392, + "relativeTime": 0.049998204087496854, + "beatIndex": 2, + "phraseIndex": 0, + "nearestBeatSec": 1.304, + "offsetMs": 88, + "segmentRole": "setup", + "strength": "medium", + "description": "基础视觉铺垫" + }, + { + "eventType": "caption", + "timeSec": 5.568, + "relativeTime": 0.19999281634998742, + "beatIndex": 9, + "phraseIndex": 1, + "nearestBeatSec": 5.87, + "offsetMs": -302, + "segmentRole": "develop", + "strength": "medium", + "description": "动态效果启动" + }, + { + "eventType": "caption", + "timeSec": 13.92, + "relativeTime": 0.49998204087496856, + "beatIndex": 21, + "phraseIndex": 2, + "nearestBeatSec": 13.696, + "offsetMs": 224, + "segmentRole": "climax", + "strength": "medium", + "description": "品牌标识全幅延展" + }, + { + "eventType": "caption", + "timeSec": 25.056, + "relativeTime": 0.8999676735749434, + "beatIndex": 38, + "phraseIndex": 4, + "nearestBeatSec": 24.783, + "offsetMs": 273, + "segmentRole": "closing", + "strength": "medium", + "description": "品牌视觉收尾" + } + ], + "cutIntervalsSec": [ + 0.5, + 27.341 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:2 镜,平均 13.9s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_b4bdec12-c79b-4ee0-a652-b29cfb3fda75", + "source": "global_sample", + "durationSec": 27.841, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 22 个,硬切 1 个。", + "真实音频 onset 7 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 1.5, + "relativeTime": 0.054, + "strength": "strong", + "energyDb": -9.234 + }, + { + "timeSec": 3, + "relativeTime": 0.108, + "strength": "strong", + "energyDb": -9.002 + }, + { + "timeSec": 4.5, + "relativeTime": 0.162, + "strength": "strong", + "energyDb": -9.096 + }, + { + "timeSec": 10.5, + "relativeTime": 0.377, + "strength": "medium", + "energyDb": -9.612 + }, + { + "timeSec": 14.5, + "relativeTime": 0.521, + "strength": "weak", + "energyDb": -9.736 + }, + { + "timeSec": 18.5, + "relativeTime": 0.664, + "strength": "medium", + "energyDb": -9.585 + }, + { + "timeSec": 22, + "relativeTime": 0.79, + "strength": "weak", + "energyDb": -9.701 + } + ], + "events": [ + { + "kind": "audio_onset", + "timeSec": 1.5, + "relativeTime": 0.054, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 3, + "relativeTime": 0.108, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 3.167, + "relativeTime": 0.114, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 3, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 3.533, + "relativeTime": 0.127, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.467, + "relativeTime": 0.16, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 4.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 4.5, + "relativeTime": 0.162, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 4.8, + "relativeTime": 0.172, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.533, + "relativeTime": 0.199, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.9, + "relativeTime": 0.212, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 6.233, + "relativeTime": 0.224, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 6.567, + "relativeTime": 0.236, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 8.8, + "relativeTime": 0.316, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 10.467, + "relativeTime": 0.376, + "strength": "weak", + "direction": "left", + "nearestOnsetSec": 10.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 10.5, + "relativeTime": 0.377, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 12.433, + "relativeTime": 0.447, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 13.667, + "relativeTime": 0.491, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 14.5, + "relativeTime": 0.521, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 15.1, + "relativeTime": 0.542, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 15.667, + "relativeTime": 0.563, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 16.867, + "relativeTime": 0.606, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 18.1, + "relativeTime": 0.65, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 18.5, + "relativeTime": 0.664, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 22, + "relativeTime": 0.79, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "reveal_pan", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "品牌信息初露 -> 基础视觉铺垫 -> 动态效果启动 -> 品牌标识全幅延展 -> 品牌视觉收尾", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "品牌信息初露", + "durationRatio": 0.05, + "intent": "第一时间同步传递品牌基础标识与发布账号信息,快速筛选对品牌感兴趣的目标受众", + "copyPattern": "静态品牌文字+账号信息同步直给", + "watchingPurpose": "开场抓停:第一时间同步传递品牌基础标识与发布账号信息,快速筛选对品牌感兴趣的目标受众" + }, + { + "role": "setup", + "label": "基础视觉铺垫", + "durationRatio": 0.15, + "intent": "让观众清晰识别品牌文字与小标识的标准组合形态,建立初始品牌认知", + "copyPattern": "固定帧展示品牌官方标准视觉组合", + "watchingPurpose": "建立背景:让观众清晰识别品牌文字与小标识的标准组合形态,建立初始品牌认知" + }, + { + "role": "develop", + "label": "动态效果启动", + "durationRatio": 0.3, + "intent": "引导观众注意力向核心品牌图形转移,铺垫动态延展的期待感", + "copyPattern": "品牌核心图形逐步放大入场", + "watchingPurpose": "推进主体:引导观众注意力向核心品牌图形转移,铺垫动态延展的期待感" + }, + { + "role": "climax", + "label": "品牌标识全幅延展", + "durationRatio": 0.4, + "intent": "最大化呈现品牌视觉的冲击力,深度强化品牌记忆点", + "copyPattern": "大尺寸渐变品牌图形全屏展示,拉满视觉张力", + "watchingPurpose": "放大重点:最大化呈现品牌视觉的冲击力,深度强化品牌记忆点" + }, + { + "role": "closing", + "label": "品牌视觉收尾", + "durationRatio": 0.1, + "intent": "完成品牌展示闭环,留给观众足够的视觉记忆停留时间", + "copyPattern": "最终定格全幅品牌标识画面", + "watchingPurpose": "收束记忆点:完成品牌展示闭环,留给观众足够的视觉记忆停留时间" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 27.841, + "shotCount": 2, + "avgShotSec": 13.92, + "cutDensity": "low", + "peakAt": 0.6, + "beatHints": [ + "低缓氛围感电子音铺垫", + "高潮部分音浪同步抬升匹配图形延展节奏" + ], + "rhythmNotes": [ + "平均 13.9s/镜,整体为 慢节奏。", + "高潮位置约在全片 60%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「无主标题,仅左上角固定账号信息条」协同", + "animation": "字幕/标题可能配合「硬切+渐变转场组合」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "无主标题,仅左上角固定账号信息条", + "stickerUsage": "无额外装饰贴纸,仅保留品牌原生视觉元素", + "coverStyle": "品牌标识居中展示+渐变背景的极简视觉风格", + "overlayStyle": "无主标题,仅左上角固定账号信息条 / 无额外装饰贴纸,仅保留品牌原生视觉元素", + "notes": [ + "画面包装迁移重点:无主标题,仅左上角固定账号信息条;无额外装饰贴纸,仅保留品牌原生视觉元素;品牌标识居中展示+渐变背景的极简视觉风格", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "硬切+渐变转场组合", + "frequency": "低频切换", + "notableTransitions": [ + "硬切+渐变转场组合" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "低缓氛围感电子音铺垫", + "高潮部分音浪同步抬升匹配图形延展节奏" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "s1", + "segmentRole": "hook", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 0.5, + "optional": false + }, + { + "slotId": "s2", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 8, + "optional": false + }, + { + "slotId": "s3", + "segmentRole": "climax", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 10, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 27.841s", + "ref": "seed:v1e00fgi0000d28bkm7og65vgt0lb2m0.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 30fps" + }, + { + "type": "scene_cut", + "detail": "2 个镜头 / 1 个切点(原始 1 个,已合并 <0.4s 密集检测)", + "ref": "0.50" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 medium;模板事件 23 个;音频 onset 7 个", + "ref": "letterbox_frame, reveal_pan, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 2 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构从快速露出基础信息抓注意力切入,逐步铺垫品牌视觉认知,再通过动态放大的强视觉冲击强化品牌记忆,最后定格画面留足记忆停留时间,全程保留发布账号信息方便感兴趣的观众溯源,低剪辑密度适配品牌视觉展示的氛围感需求,避免过多镜头切换分散观众对品牌标识本身的注意力,完全适配品牌logo展示类内容的传播逻辑。", + "createdAt": "2026-06-08T09:57:59.023Z", + "updatedAt": "2026-06-08T09:58:00.165Z" + }, + { + "id": "pattern_7a9504ce", + "scope": "global", + "sourceSampleId": "22367d15-b6e4-4838-b657-5ba1cd1b07a3", + "name": "v1e00fgi0000d37a8svog65i5tpks8sg · 展示模式", + "summary": "该样例是 29s 的 展示 视频,结构为 暖调氛围感开篇 -> 创意转场效果铺垫 -> 同风格蒙太奇主体展开 -> 质感细节强化记忆点 -> 教程路径引导收尾,节奏为 中等节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "full_story", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "weak", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:暖调氛围感开篇 -> 教程路径引导收尾", + "formula": "暖调氛围感开篇 -> 创意转场效果铺垫 -> 同风格蒙太奇主体展开 -> 质感细节强化记忆点 -> 教程路径引导收尾", + "source": { + "filename": "v1e00fgi0000d37a8svog65i5tpks8sg.MP4", + "durationSec": 29.065, + "aspectRatio": "1280:720", + "shotCount": 15 + }, + "segments": [ + { + "role": "hook", + "label": "暖调氛围感开篇", + "durationRatio": 0.2, + "intent": "第一时间用极具辨识度的复古胶片暖调画面抓住偏好城市纪实风格的观众注意力", + "copyPattern": "高辨识度风格场景直出,快速建立统一视觉认知", + "watchingPurpose": "开场抓停:第一时间用极具辨识度的复古胶片暖调画面抓住偏好城市纪实风格的观众注意力" + }, + { + "role": "setup", + "label": "创意转场效果铺垫", + "durationRatio": 0.15, + "intent": "展示第一个特色剪辑特效,自然引出后续系列城市街景内容", + "copyPattern": "特殊分屏转场过渡,平滑衔接前后不同场景", + "watchingPurpose": "建立背景:展示第一个特色剪辑特效,自然引出后续系列城市街景内容" + }, + { + "role": "develop", + "label": "同风格蒙太奇主体展开", + "durationRatio": 0.45, + "intent": "集中串联全部经过调色剪辑的城市日常片段,完整呈现复古胶片风成片效果", + "copyPattern": "统一滤镜下多场景蒙太奇拼接,持续输出氛围感内容", + "watchingPurpose": "推进主体:集中串联全部经过调色剪辑的城市日常片段,完整呈现复古胶片风成片效果" + }, + { + "role": "climax", + "label": "质感细节强化记忆点", + "durationRatio": 0.12, + "intent": "用局部特写画面突出剪辑调色后的画面精细度,强化观众对效果的感知", + "copyPattern": "局部细节特写放大,凸显画面质感优势", + "watchingPurpose": "放大重点:用局部特写画面突出剪辑调色后的画面精细度,强化观众对效果的感知" + }, + { + "role": "closing", + "label": "教程路径引导收尾", + "durationRatio": 0.08, + "intent": "明确告知观众对应剪辑教程的获取方式,完成内容引流", + "copyPattern": "CTA卡片直接引导,清晰给出后续操作路径", + "watchingPurpose": "收束记忆点:明确告知观众对应剪辑教程的获取方式,完成内容引流" + } + ], + "pacing": { + "durationSec": 29.065, + "shotCount": 15, + "avgShotSec": 1.94, + "cutDensity": "medium", + "peakAt": 0.6, + "beatHints": [ + "复古舒缓港风BGM卡点", + "每2秒左右切镜匹配轻鼓点节奏" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "顶部窄条复古暖黄细体字,半透明不遮挡画面主体", + "stickerUsage": "仅角落放置低透明度账号水印,无多余装饰贴纸", + "transitionStyle": "硬切+少量淡入淡出,匹配蒙太奇快节奏同时保留氛围感", + "coverStyle": "暖调骑行背影画面叠加轻微胶片颗粒边框,标注港风城市剪辑字样" + }, + "storySkeleton": { + "arcType": "展示 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "第一时间用极具辨识度的复古胶片暖调画面抓住偏好城市纪实风格的观众注意力", + "展示第一个特色剪辑特效,自然引出后续系列城市街景内容", + "集中串联全部经过调色剪辑的城市日常片段,完整呈现复古胶片风成片效果", + "用局部特写画面突出剪辑调色后的画面精细度,强化观众对效果的感知", + "明确告知观众对应剪辑教程的获取方式,完成内容引流" + ], + "hookStyle": "高辨识度风格场景直出,快速建立统一视觉认知", + "turnOrProofStyle": "统一滤镜下多场景蒙太奇拼接,持续输出氛围感内容", + "payoffStyle": "CTA卡片直接引导,清晰给出后续操作路径", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "showcase" + ], + "assetRequirements": [ + "b_roll", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_crossfade", + "name": "可执行转场:crossfade", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "crossfade", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=crossfade,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunction": [ + "opening_hook", + "transition", + "cta" + ], + "appliesToAssetType": [ + "text" + ], + "appliesToVisualCluster": [], + "beatPlacement": "phrase boundary or shot boundary", + "cardAnimationPreset": "soft_crossfade", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "remotion", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ], + "packagingPattern": { + "titleBarStyle": "顶部窄条复古暖黄细体字,半透明不遮挡画面主体", + "stickerUsage": "仅角落放置低透明度账号水印,无多余装饰贴纸", + "coverStyle": "暖调骑行背影画面叠加轻微胶片颗粒边框,标注港风城市剪辑字样", + "overlayStyle": "顶部窄条复古暖黄细体字,半透明不遮挡画面主体 / 仅角落放置低透明度账号水印,无多余装饰贴纸", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 29.065, + "bpm": 112, + "beatCount": 54, + "beatStability": 0.42322097378277146, + "onsetDensity": 0.5160846378806124, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714 + ], + "sections": [ + { + "startSec": 0, + "endSec": 5.232, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286 + ] + }, + { + "startSec": 5.232, + "endSec": 15.986, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 6.429, + 8.571, + 10.714, + 12.857, + 15 + ] + }, + { + "startSec": 15.986, + "endSec": 21.218, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 17.143, + 19.286 + ] + }, + { + "startSec": 21.218, + "endSec": 29.065, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 21.429, + 23.571, + 25.714, + 27.857 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_22367d15-b6e4-4838-b657-5ba1cd1b07a3", + "source": "global_sample", + "durationSec": 29.065, + "music": { + "hasAudio": true, + "durationSec": 29.065, + "bpm": 112, + "beatCount": 54, + "beatStability": 0.42322097378277146, + "onsetDensity": 0.5160846378806124, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714 + ], + "sections": [ + { + "startSec": 0, + "endSec": 5.232, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286 + ] + }, + { + "startSec": 5.232, + "endSec": 15.986, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 6.429, + 8.571, + 10.714, + 12.857, + 15 + ] + }, + { + "startSec": 15.986, + "endSec": 21.218, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 17.143, + 19.286 + ] + }, + { + "startSec": 21.218, + "endSec": 29.065, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 21.429, + 23.571, + 25.714, + 27.857 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 15, + "avgShotSec": 1.94, + "peakAt": 0.6, + "cutEveryBeats": 2.492, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "暖调氛围感开篇" + }, + { + "eventType": "caption", + "timeSec": 5.813, + "relativeTime": 0.19999999999999998, + "beatIndex": 11, + "phraseIndex": 1, + "nearestBeatSec": 5.893, + "offsetMs": -80, + "segmentRole": "setup", + "strength": "medium", + "description": "创意转场效果铺垫" + }, + { + "eventType": "cut", + "timeSec": 6.548, + "relativeTime": 0.22528814725615, + "beatIndex": 12, + "phraseIndex": 1, + "nearestBeatSec": 6.429, + "offsetMs": 119, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 7.007, + "relativeTime": 0.24108033717529673, + "beatIndex": 13, + "phraseIndex": 1, + "nearestBeatSec": 6.964, + "offsetMs": 43, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 9.927, + "relativeTime": 0.3415448133493893, + "beatIndex": 19, + "phraseIndex": 2, + "nearestBeatSec": 10.179, + "offsetMs": -252, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 10.173, + "relativeTime": 0.35000860141063134, + "beatIndex": 19, + "phraseIndex": 2, + "nearestBeatSec": 10.179, + "offsetMs": -6, + "segmentRole": "develop", + "strength": "medium", + "description": "同风格蒙太奇主体展开" + }, + { + "eventType": "cut", + "timeSec": 11.47, + "relativeTime": 0.39463271976604164, + "beatIndex": 21, + "phraseIndex": 2, + "nearestBeatSec": 11.25, + "offsetMs": 220, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 12.804, + "relativeTime": 0.4405298468948908, + "beatIndex": 24, + "phraseIndex": 3, + "nearestBeatSec": 12.857, + "offsetMs": -53, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 14.139, + "relativeTime": 0.48646137966626524, + "beatIndex": 26, + "phraseIndex": 3, + "nearestBeatSec": 13.929, + "offsetMs": 210, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 15.516, + "relativeTime": 0.5338379494237054, + "beatIndex": 29, + "phraseIndex": 3, + "nearestBeatSec": 15.536, + "offsetMs": -20, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 16.85, + "relativeTime": 0.5797350765525546, + "beatIndex": 31, + "phraseIndex": 3, + "nearestBeatSec": 16.607, + "offsetMs": 243, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 18.185, + "relativeTime": 0.625666609323929, + "beatIndex": 34, + "phraseIndex": 4, + "nearestBeatSec": 18.214, + "offsetMs": -29, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 19.52, + "relativeTime": 0.6715981420953036, + "beatIndex": 36, + "phraseIndex": 4, + "nearestBeatSec": 19.286, + "offsetMs": 234, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 20.812, + "relativeTime": 0.7160502322380871, + "beatIndex": 39, + "phraseIndex": 4, + "nearestBeatSec": 20.893, + "offsetMs": -81, + "strength": "medium", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 21.813, + "relativeTime": 0.7504902804059865, + "beatIndex": 41, + "phraseIndex": 5, + "nearestBeatSec": 21.964, + "offsetMs": -151, + "strength": "medium", + "description": "样例第 12 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 23.252, + "relativeTime": 0.7999999999999999, + "beatIndex": 43, + "phraseIndex": 5, + "nearestBeatSec": 23.036, + "offsetMs": 216, + "segmentRole": "climax", + "strength": "medium", + "description": "质感细节强化记忆点" + }, + { + "eventType": "cut", + "timeSec": 23.524, + "relativeTime": 0.8093583347669018, + "beatIndex": 44, + "phraseIndex": 5, + "nearestBeatSec": 23.571, + "offsetMs": -47, + "strength": "medium", + "description": "样例第 13 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 26.026, + "relativeTime": 0.8954412523653879, + "beatIndex": 49, + "phraseIndex": 6, + "nearestBeatSec": 26.25, + "offsetMs": -224, + "strength": "strong", + "description": "样例第 14 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 26.74, + "relativeTime": 0.9200068811285049, + "beatIndex": 50, + "phraseIndex": 6, + "nearestBeatSec": 26.786, + "offsetMs": -46, + "segmentRole": "closing", + "strength": "medium", + "description": "教程路径引导收尾" + } + ], + "cutIntervalsSec": [ + 6.548, + 0.459, + 2.92, + 1.543, + 1.334, + 1.335, + 1.377, + 1.334, + 1.335, + 1.335, + 1.292, + 1.001, + 1.711, + 2.502, + 3.039 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:15 镜,平均 1.9s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_22367d15-b6e4-4838-b657-5ba1cd1b07a3", + "source": "global_sample", + "durationSec": 29.065, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 10 个,硬切 14 个。", + "真实音频 onset 12 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 8.5, + "relativeTime": 0.292, + "strength": "strong", + "energyDb": -11.9 + }, + { + "timeSec": 10, + "relativeTime": 0.344, + "strength": "strong", + "energyDb": -12.718 + }, + { + "timeSec": 11.5, + "relativeTime": 0.396, + "strength": "weak", + "energyDb": -13.908 + }, + { + "timeSec": 12.5, + "relativeTime": 0.43, + "strength": "medium", + "energyDb": -13.263 + }, + { + "timeSec": 14, + "relativeTime": 0.482, + "strength": "strong", + "energyDb": -12.905 + }, + { + "timeSec": 16.5, + "relativeTime": 0.568, + "strength": "medium", + "energyDb": -12.986 + }, + { + "timeSec": 18, + "relativeTime": 0.619, + "strength": "medium", + "energyDb": -13.237 + }, + { + "timeSec": 19.5, + "relativeTime": 0.671, + "strength": "strong", + "energyDb": -12.9 + }, + { + "timeSec": 20.5, + "relativeTime": 0.705, + "strength": "strong", + "energyDb": -12.901 + }, + { + "timeSec": 22, + "relativeTime": 0.757, + "strength": "weak", + "energyDb": -13.426 + }, + { + "timeSec": 23.5, + "relativeTime": 0.809, + "strength": "medium", + "energyDb": -13.385 + }, + { + "timeSec": 24.5, + "relativeTime": 0.843, + "strength": "strong", + "energyDb": -12.968 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 0.459, + "relativeTime": 0.016, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 1.043, + "relativeTime": 0.036, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 1.752, + "relativeTime": 0.06, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.461, + "relativeTime": 0.085, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 3.128, + "relativeTime": 0.108, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 3.754, + "relativeTime": 0.129, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.421, + "relativeTime": 0.152, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.13, + "relativeTime": 0.177, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.923, + "relativeTime": 0.204, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 6.256, + "relativeTime": 0.215, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 8.5, + "relativeTime": 0.292, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 10, + "relativeTime": 0.344, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 11.5, + "relativeTime": 0.396, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 12.5, + "relativeTime": 0.43, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 14, + "relativeTime": 0.482, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 16.5, + "relativeTime": 0.568, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 18, + "relativeTime": 0.619, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 19.5, + "relativeTime": 0.671, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 20.5, + "relativeTime": 0.705, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 22, + "relativeTime": 0.757, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 23.5, + "relativeTime": 0.809, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 24.5, + "relativeTime": 0.843, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "ken_burns_in", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "暖调氛围感开篇 -> 创意转场效果铺垫 -> 同风格蒙太奇主体展开 -> 质感细节强化记忆点 -> 教程路径引导收尾", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "暖调氛围感开篇", + "durationRatio": 0.2, + "intent": "第一时间用极具辨识度的复古胶片暖调画面抓住偏好城市纪实风格的观众注意力", + "copyPattern": "高辨识度风格场景直出,快速建立统一视觉认知", + "watchingPurpose": "开场抓停:第一时间用极具辨识度的复古胶片暖调画面抓住偏好城市纪实风格的观众注意力" + }, + { + "role": "setup", + "label": "创意转场效果铺垫", + "durationRatio": 0.15, + "intent": "展示第一个特色剪辑特效,自然引出后续系列城市街景内容", + "copyPattern": "特殊分屏转场过渡,平滑衔接前后不同场景", + "watchingPurpose": "建立背景:展示第一个特色剪辑特效,自然引出后续系列城市街景内容" + }, + { + "role": "develop", + "label": "同风格蒙太奇主体展开", + "durationRatio": 0.45, + "intent": "集中串联全部经过调色剪辑的城市日常片段,完整呈现复古胶片风成片效果", + "copyPattern": "统一滤镜下多场景蒙太奇拼接,持续输出氛围感内容", + "watchingPurpose": "推进主体:集中串联全部经过调色剪辑的城市日常片段,完整呈现复古胶片风成片效果" + }, + { + "role": "climax", + "label": "质感细节强化记忆点", + "durationRatio": 0.12, + "intent": "用局部特写画面突出剪辑调色后的画面精细度,强化观众对效果的感知", + "copyPattern": "局部细节特写放大,凸显画面质感优势", + "watchingPurpose": "放大重点:用局部特写画面突出剪辑调色后的画面精细度,强化观众对效果的感知" + }, + { + "role": "closing", + "label": "教程路径引导收尾", + "durationRatio": 0.08, + "intent": "明确告知观众对应剪辑教程的获取方式,完成内容引流", + "copyPattern": "CTA卡片直接引导,清晰给出后续操作路径", + "watchingPurpose": "收束记忆点:明确告知观众对应剪辑教程的获取方式,完成内容引流" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 29.065, + "shotCount": 15, + "avgShotSec": 1.94, + "cutDensity": "medium", + "peakAt": 0.6, + "beatHints": [ + "复古舒缓港风BGM卡点", + "每2秒左右切镜匹配轻鼓点节奏" + ], + "rhythmNotes": [ + "平均 1.9s/镜,整体为 中等节奏。", + "高潮位置约在全片 60%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「顶部窄条复古暖黄细体字,半透明不遮挡画面主体」协同", + "animation": "字幕/标题可能配合「硬切+少量淡入淡出,匹配蒙太奇快节奏同时保留氛围感」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "顶部窄条复古暖黄细体字,半透明不遮挡画面主体", + "stickerUsage": "仅角落放置低透明度账号水印,无多余装饰贴纸", + "coverStyle": "暖调骑行背影画面叠加轻微胶片颗粒边框,标注港风城市剪辑字样", + "overlayStyle": "顶部窄条复古暖黄细体字,半透明不遮挡画面主体 / 仅角落放置低透明度账号水印,无多余装饰贴纸", + "notes": [ + "画面包装迁移重点:顶部窄条复古暖黄细体字,半透明不遮挡画面主体;仅角落放置低透明度账号水印,无多余装饰贴纸;暖调骑行背影画面叠加轻微胶片颗粒边框,标注港风城市剪辑字样", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "硬切+少量淡入淡出,匹配蒙太奇快节奏同时保留氛围感", + "frequency": "中等频率切换", + "notableTransitions": [ + "硬切+少量淡入淡出,匹配蒙太奇快节奏同时保留氛围感" + ], + "executableTechniques": [ + { + "id": "tech_transition_crossfade", + "name": "可执行转场:crossfade", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "crossfade", + "implementationNotes": "已映射到 Timeline.transitionPreset=crossfade,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunctions": [ + "opening_hook", + "transition", + "cta" + ], + "requiredRenderer": "remotion", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "复古舒缓港风BGM卡点", + "每2秒左右切镜匹配轻鼓点节奏" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "s_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 6, + "optional": false + }, + { + "slotId": "s_002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 13, + "optional": false + }, + { + "slotId": "s_003", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 29.065s", + "ref": "seed:v1e00fgi0000d37a8svog65i5tpks8sg.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 23.976fps" + }, + { + "type": "scene_cut", + "detail": "15 个镜头 / 14 个切点(原始 22 个,已合并 <0.4s 密集检测)", + "ref": "6.55, 7.01, 9.93, 11.47, 12.80, 14.14, 15.52, 16.85, 18.18, 19.52, 20.81, 21.81, 23.52, 26.03" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 low;模板事件 22 个;音频 onset 12 个", + "ref": "letterbox_frame, ken_burns_in, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该编排从开篇就用高辨识度的复古暖调画面快速筛选目标受众,避免无关用户流失,中段用同风格高密度蒙太奇快速输出大量优质氛围感内容,最大化展示剪辑效果的核心优势,细节特写部分进一步强化观众对调色质感的记忆,最后直接给出教程获取路径承接用户的学习需求,全程低干扰的字幕水印设置不会破坏整体城市氛围感,完全适配展示类内容的观看体验。", + "createdAt": "2026-06-08T09:58:25.152Z", + "updatedAt": "2026-06-08T10:01:57.022Z" + }, + { + "id": "pattern_d4af0468", + "scope": "global", + "sourceSampleId": "c19ddce9-6600-4cbd-be85-47e268223cec", + "name": "v1e00fgi0000d754ohvog65id7l8fbd0 · Vlog模式", + "summary": "该样例是 17s 的 Vlog 视频,结构为 居家日常出镜 -> 古风氛围感核心展示 -> 延伸内容引导,节奏为 慢节奏。", + "videoGenre": "vlog", + "tags": [ + "vlog", + "low", + "hook", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "thin_pattern", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "这条样例故事结构偏薄,建议作为 secondary story 或剪辑参考。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "Vlog:居家日常出镜 -> 延伸内容引导", + "formula": "居家日常出镜 -> 古风氛围感核心展示 -> 延伸内容引导", + "source": { + "filename": "v1e00fgi0000d754ohvog65id7l8fbd0.MP4", + "durationSec": 17.204, + "aspectRatio": "1280:720", + "shotCount": 3 + }, + "segments": [ + { + "role": "hook", + "label": "居家日常出镜", + "durationRatio": 0.2, + "intent": "快速建立博主生活化亲切感,第一时间抓住观众注意力", + "copyPattern": "博主直面镜头展示松弛日常状态,无冗余铺垫", + "watchingPurpose": "开场抓停:快速建立博主生活化亲切感,第一时间抓住观众注意力" + }, + { + "role": "climax", + "label": "古风氛围感核心展示", + "durationRatio": 0.625, + "intent": "呈现前后状态反差,输出国风审美核心看点", + "copyPattern": "沉浸式无台词画面展示造型与场景氛围,传递情绪价值", + "watchingPurpose": "放大重点:呈现前后状态反差,输出国风审美核心看点" + }, + { + "role": "closing", + "label": "延伸内容引导", + "durationRatio": 0.175, + "intent": "引导观众获取更多相关内容,完成流量闭环", + "copyPattern": "直接给出明确搜索路径,降低观众行动门槛", + "watchingPurpose": "收束记忆点:引导观众获取更多相关内容,完成流量闭环" + } + ], + "pacing": { + "durationSec": 17.204, + "shotCount": 3, + "avgShotSec": 5.73, + "cutDensity": "low", + "peakAt": 0.5, + "beatHints": [ + "轻缓日常BGM开场", + "国风舒缓BGM烘托展示段氛围", + "BGM渐弱适配引导信息输出" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "左上角固定展示账号ID与昵称的窄条样式", + "stickerUsage": "无额外装饰贴纸,保持画面干净", + "transitionStyle": "无花哨特效的硬切转场", + "coverStyle": "博主古风持香的氛围感核心帧画面" + }, + "storySkeleton": { + "arcType": "Vlog / hook -> climax -> closing", + "segmentRoles": [ + "hook", + "climax", + "closing" + ], + "emotionalCurve": [ + "快速建立博主生活化亲切感,第一时间抓住观众注意力", + "呈现前后状态反差,输出国风审美核心看点", + "引导观众获取更多相关内容,完成流量闭环" + ], + "hookStyle": "博主直面镜头展示松弛日常状态,无冗余铺垫", + "turnOrProofStyle": "沉浸式无台词画面展示造型与场景氛围,传递情绪价值", + "payoffStyle": "直接给出明确搜索路径,降低观众行动门槛", + "requiredStoryFunctions": [ + "opening_hook", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "vlog" + ], + "assetRequirements": [ + "talking_head", + "b_roll", + "usage_demo", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "左上角固定展示账号ID与昵称的窄条样式", + "stickerUsage": "无额外装饰贴纸,保持画面干净", + "coverStyle": "博主古风持香的氛围感核心帧画面", + "overlayStyle": "左上角固定展示账号ID与昵称的窄条样式 / 无额外装饰贴纸,保持画面干净", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 17.204, + "bpm": 92, + "beatCount": 26, + "beatStability": 0.24446395473612326, + "onsetDensity": 0.17437805161590328, + "energyShape": "mid_peak", + "peakAt": 0.5, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435, + 13.043, + 15.652 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435, + 15.652 + ], + "sections": [ + { + "startSec": 0, + "endSec": 3.097, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.609 + ] + }, + { + "startSec": 3.097, + "endSec": 9.462, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 5.217, + 7.826 + ] + }, + { + "startSec": 9.462, + "endSec": 12.559, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 10.435 + ] + }, + { + "startSec": 12.559, + "endSec": 17.204, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 13.043, + 15.652 + ] + } + ], + "tags": [ + "vlog", + "density:low", + "bpm:92", + "shape:mid_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_c19ddce9-6600-4cbd-be85-47e268223cec", + "source": "global_sample", + "durationSec": 17.204, + "music": { + "hasAudio": true, + "durationSec": 17.204, + "bpm": 92, + "beatCount": 26, + "beatStability": 0.24446395473612326, + "onsetDensity": 0.17437805161590328, + "energyShape": "mid_peak", + "peakAt": 0.5, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435, + 13.043, + 15.652 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435, + 15.652 + ], + "sections": [ + { + "startSec": 0, + "endSec": 3.097, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.609 + ] + }, + { + "startSec": 3.097, + "endSec": 9.462, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 5.217, + 7.826 + ] + }, + { + "startSec": 9.462, + "endSec": 12.559, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 10.435 + ] + }, + { + "startSec": 12.559, + "endSec": 17.204, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 13.043, + 15.652 + ] + } + ], + "tags": [ + "vlog", + "density:low", + "bpm:92", + "shape:mid_peak" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 3, + "avgShotSec": 5.73, + "peakAt": 0.5, + "cutEveryBeats": 5.239, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "居家日常出镜" + }, + { + "eventType": "cut", + "timeSec": 3.417, + "relativeTime": 0.19861660079051383, + "beatIndex": 5, + "phraseIndex": 0, + "nearestBeatSec": 3.261, + "offsetMs": 156, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 3.441, + "relativeTime": 0.20001162520344104, + "beatIndex": 5, + "phraseIndex": 0, + "nearestBeatSec": 3.261, + "offsetMs": 180, + "segmentRole": "climax", + "strength": "medium", + "description": "古风氛围感核心展示" + }, + { + "eventType": "cut", + "timeSec": 14.183, + "relativeTime": 0.8244013020227854, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 14.348, + "offsetMs": -165, + "strength": "strong", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 14.194, + "relativeTime": 0.8250406882120437, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 14.348, + "offsetMs": -154, + "segmentRole": "closing", + "strength": "medium", + "description": "延伸内容引导" + } + ], + "cutIntervalsSec": [ + 3.417, + 10.766, + 3.021 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:3 镜,平均 5.7s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_c19ddce9-6600-4cbd-be85-47e268223cec", + "source": "global_sample", + "durationSec": 17.204, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": false, + "hasViewportSlides": false, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 0 个,硬切 2 个。", + "真实音频 onset 4 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 4.5, + "relativeTime": 0.262, + "strength": "strong", + "energyDb": -13.945 + }, + { + "timeSec": 6, + "relativeTime": 0.349, + "strength": "strong", + "energyDb": -14.192 + }, + { + "timeSec": 8.5, + "relativeTime": 0.494, + "strength": "medium", + "energyDb": -14.272 + }, + { + "timeSec": 13.5, + "relativeTime": 0.785, + "strength": "strong", + "energyDb": -13.409 + } + ], + "events": [ + { + "kind": "audio_onset", + "timeSec": 4.5, + "relativeTime": 0.262, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 6, + "relativeTime": 0.349, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 8.5, + "relativeTime": 0.494, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 13.5, + "relativeTime": 0.785, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "ken_burns_in", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "居家日常出镜 -> 古风氛围感核心展示 -> 延伸内容引导", + "segmentCount": 3, + "segments": [ + { + "role": "hook", + "label": "居家日常出镜", + "durationRatio": 0.2, + "intent": "快速建立博主生活化亲切感,第一时间抓住观众注意力", + "copyPattern": "博主直面镜头展示松弛日常状态,无冗余铺垫", + "watchingPurpose": "开场抓停:快速建立博主生活化亲切感,第一时间抓住观众注意力" + }, + { + "role": "climax", + "label": "古风氛围感核心展示", + "durationRatio": 0.625, + "intent": "呈现前后状态反差,输出国风审美核心看点", + "copyPattern": "沉浸式无台词画面展示造型与场景氛围,传递情绪价值", + "watchingPurpose": "放大重点:呈现前后状态反差,输出国风审美核心看点" + }, + { + "role": "closing", + "label": "延伸内容引导", + "durationRatio": 0.175, + "intent": "引导观众获取更多相关内容,完成流量闭环", + "copyPattern": "直接给出明确搜索路径,降低观众行动门槛", + "watchingPurpose": "收束记忆点:引导观众获取更多相关内容,完成流量闭环" + } + ], + "notes": [ + "脚本结构由 3 个段落组成,按 hook -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 17.204, + "shotCount": 3, + "avgShotSec": 5.73, + "cutDensity": "low", + "peakAt": 0.5, + "beatHints": [ + "轻缓日常BGM开场", + "国风舒缓BGM烘托展示段氛围", + "BGM渐弱适配引导信息输出" + ], + "rhythmNotes": [ + "平均 5.7s/镜,整体为 慢节奏。", + "高潮位置约在全片 50%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「左上角固定展示账号ID与昵称的窄条样式」协同", + "animation": "字幕/标题可能配合「无花哨特效的硬切转场」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "左上角固定展示账号ID与昵称的窄条样式", + "stickerUsage": "无额外装饰贴纸,保持画面干净", + "coverStyle": "博主古风持香的氛围感核心帧画面", + "overlayStyle": "左上角固定展示账号ID与昵称的窄条样式 / 无额外装饰贴纸,保持画面干净", + "notes": [ + "画面包装迁移重点:左上角固定展示账号ID与昵称的窄条样式;无额外装饰贴纸,保持画面干净;博主古风持香的氛围感核心帧画面", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "无花哨特效的硬切转场", + "frequency": "低频切换", + "notableTransitions": [ + "无花哨特效的硬切转场" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻缓日常BGM开场", + "国风舒缓BGM烘托展示段氛围", + "BGM渐弱适配引导信息输出" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "talking_head", + "b_roll" + ], + "minDurationSec": 3, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "climax", + "requiredAssetTypes": [ + "usage_demo", + "b_roll" + ], + "minDurationSec": 10, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 17.204s", + "ref": "seed:v1e00fgi0000d754ohvog65id7l8fbd0.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 54.922fps" + }, + { + "type": "scene_cut", + "detail": "3 个镜头 / 2 个切点(原始 2 个,已合并 <0.4s 密集检测)", + "ref": "3.42, 14.18" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 low;模板事件 4 个;音频 onset 4 个", + "ref": "letterbox_frame, ken_burns_in, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 3 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "开篇用无距离感的居家日常快速留住泛流量观众,中段用反差感的国风沉浸式展示作为核心记忆点,满足观众的审美需求,低切密度的节奏适配国风内容的舒缓调性,避免频繁转场破坏氛围感,最后直接给出清晰的延伸内容搜索指引,降低观众获取更多相关信息的行动成本,完成内容价值的延伸。", + "createdAt": "2026-06-08T09:59:16.156Z", + "updatedAt": "2026-06-08T10:02:01.558Z" + }, + { + "id": "pattern_4e30c1d1", + "scope": "global", + "sourceSampleId": "4e2adf18-611e-47c1-9da4-2c9d3d25e4be", + "name": "v0200fg10000c615tsbc77u3e5mi9m50 · 展示模式", + "summary": "该样例是 60s 的 展示 视频,结构为 抽象视觉开场 -> 风格铺垫过渡 -> 多场景界面展示 -> 核心协作特性集中呈现 -> 品牌标识落版,节奏为 中等节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "story_candidate", + "ctaType": "generic_next_action", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:抽象视觉开场 -> 品牌标识落版", + "formula": "抽象视觉开场 -> 风格铺垫过渡 -> 多场景界面展示 -> 核心协作特性集中呈现 -> 品牌标识落版", + "source": { + "filename": "v0200fg10000c615tsbc77u3e5mi9m50.MP4", + "durationSec": 60.074, + "aspectRatio": "1280:720", + "shotCount": 16 + }, + "segments": [ + { + "role": "hook", + "label": "抽象视觉开场", + "durationRatio": 0.1, + "intent": "第一时间用辨识度高的动态视觉抓住观众注意力,建立统一风格认知", + "copyPattern": "标志性抽象图形悬念开场", + "watchingPurpose": "开场抓停:第一时间用辨识度高的动态视觉抓住观众注意力,建立统一风格认知" + }, + { + "role": "setup", + "label": "风格铺垫过渡", + "durationRatio": 0.2, + "intent": "通过系列抽象动态元素铺垫品牌专属的渐变视觉体系,引导观众进入展示语境", + "copyPattern": "统一视觉符号递进铺垫", + "watchingPurpose": "建立背景:通过系列抽象动态元素铺垫品牌专属的渐变视觉体系,引导观众进入展示语境" + }, + { + "role": "develop", + "label": "多场景界面展示", + "durationRatio": 0.4, + "intent": "依次呈现不同办公设备、不同使用场景下的产品界面,传递功能覆盖广度", + "copyPattern": "多场景功能逐一铺陈", + "watchingPurpose": "推进主体:依次呈现不同办公设备、不同使用场景下的产品界面,传递功能覆盖广度" + }, + { + "role": "climax", + "label": "核心协作特性集中呈现", + "durationRatio": 0.2, + "intent": "高密度展示多窗口、多用户协同的办公界面,突出产品核心协作价值", + "copyPattern": "核心价值密集输出", + "watchingPurpose": "放大重点:高密度展示多窗口、多用户协同的办公界面,突出产品核心协作价值" + }, + { + "role": "closing", + "label": "品牌标识落版", + "durationRatio": 0.1, + "intent": "回归品牌核心视觉符号,强化观众对品牌的最终记忆点", + "copyPattern": "品牌符号闭环收尾", + "watchingPurpose": "收束记忆点:回归品牌核心视觉符号,强化观众对品牌的最终记忆点" + } + ], + "pacing": { + "durationSec": 60.074, + "shotCount": 16, + "avgShotSec": 3.75, + "cutDensity": "medium", + "peakAt": 0.75, + "beatHints": [ + "轻电子音渐入", + "转场卡点音效", + "节奏逐步上扬", + "品牌落版重音" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "无显性标题栏,全视觉流叙事", + "stickerUsage": "无额外装饰贴纸,统一使用品牌专属渐变几何元素", + "transitionStyle": "流体动态无缝转场", + "coverStyle": "核心抽象品牌图形+品牌专属紫蓝渐变背景主视觉" + }, + "storySkeleton": { + "arcType": "展示 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "第一时间用辨识度高的动态视觉抓住观众注意力,建立统一风格认知", + "通过系列抽象动态元素铺垫品牌专属的渐变视觉体系,引导观众进入展示语境", + "依次呈现不同办公设备、不同使用场景下的产品界面,传递功能覆盖广度", + "高密度展示多窗口、多用户协同的办公界面,突出产品核心协作价值", + "回归品牌核心视觉符号,强化观众对品牌的最终记忆点" + ], + "hookStyle": "标志性抽象图形悬念开场", + "turnOrProofStyle": "多场景功能逐一铺陈", + "payoffStyle": "品牌符号闭环收尾", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "showcase" + ], + "assetRequirements": [ + "b_roll", + "text_card", + "usage_demo", + "product_closeup" + ] + }, + "editingTechniques": [ + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "无显性标题栏,全视觉流叙事", + "stickerUsage": "无额外装饰贴纸,统一使用品牌专属渐变几何元素", + "coverStyle": "核心抽象品牌图形+品牌专属紫蓝渐变背景主视觉", + "overlayStyle": "无显性标题栏,全视觉流叙事 / 无额外装饰贴纸,统一使用品牌专属渐变几何元素", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 4 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 60.074, + "bpm": 112, + "beatCount": 112, + "beatStability": 0.6102350578785731, + "onsetDensity": 0.26633818290774713, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714, + 30, + 34.286, + 38.571, + 42.857, + 47.143, + 51.429, + 55.714 + ], + "sections": [ + { + "startSec": 0, + "endSec": 10.813, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714 + ] + }, + { + "startSec": 10.813, + "endSec": 40.85, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714 + ] + }, + { + "startSec": 40.85, + "endSec": 51.663, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 42.857, + 45, + 47.143, + 49.286, + 51.429 + ] + }, + { + "startSec": 51.663, + "endSec": 60.074, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 53.571, + 55.714, + 57.857 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_4e2adf18-611e-47c1-9da4-2c9d3d25e4be", + "source": "global_sample", + "durationSec": 60.074, + "music": { + "hasAudio": true, + "durationSec": 60.074, + "bpm": 112, + "beatCount": 112, + "beatStability": 0.6102350578785731, + "onsetDensity": 0.26633818290774713, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714, + 30, + 34.286, + 38.571, + 42.857, + 47.143, + 51.429, + 55.714 + ], + "sections": [ + { + "startSec": 0, + "endSec": 10.813, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714 + ] + }, + { + "startSec": 10.813, + "endSec": 40.85, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714 + ] + }, + { + "startSec": 40.85, + "endSec": 51.663, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 42.857, + 45, + 47.143, + 49.286, + 51.429 + ] + }, + { + "startSec": 51.663, + "endSec": 60.074, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 53.571, + 55.714, + 57.857 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 16, + "avgShotSec": 3.75, + "peakAt": 0.75, + "cutEveryBeats": 7.902, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "抽象视觉开场" + }, + { + "eventType": "cut", + "timeSec": 2.3, + "relativeTime": 0.038286113792988644, + "beatIndex": 4, + "phraseIndex": 0, + "nearestBeatSec": 2.143, + "offsetMs": 157, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 5.3, + "relativeTime": 0.08822452308819123, + "beatIndex": 10, + "phraseIndex": 1, + "nearestBeatSec": 5.357, + "offsetMs": -57, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 6.007, + "relativeTime": 0.0999933415454273, + "beatIndex": 11, + "phraseIndex": 1, + "nearestBeatSec": 5.893, + "offsetMs": 114, + "segmentRole": "setup", + "strength": "medium", + "description": "风格铺垫过渡" + }, + { + "eventType": "cut", + "timeSec": 10.967, + "relativeTime": 0.18255817824682893, + "beatIndex": 20, + "phraseIndex": 2, + "nearestBeatSec": 10.714, + "offsetMs": 253, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 17.433, + "relativeTime": 0.29019209641442223, + "beatIndex": 33, + "phraseIndex": 4, + "nearestBeatSec": 17.679, + "offsetMs": -246, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 18.022, + "relativeTime": 0.29999667077271364, + "beatIndex": 34, + "phraseIndex": 4, + "nearestBeatSec": 18.214, + "offsetMs": -192, + "segmentRole": "develop", + "strength": "medium", + "description": "多场景界面展示" + }, + { + "eventType": "cut", + "timeSec": 23.633, + "relativeTime": 0.3933981422911742, + "beatIndex": 44, + "phraseIndex": 5, + "nearestBeatSec": 23.571, + "offsetMs": 62, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 27.933, + "relativeTime": 0.46497652894763125, + "beatIndex": 52, + "phraseIndex": 6, + "nearestBeatSec": 27.857, + "offsetMs": 76, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 32.967, + "relativeTime": 0.5487731797449812, + "beatIndex": 62, + "phraseIndex": 7, + "nearestBeatSec": 33.214, + "offsetMs": -247, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 39.401, + "relativeTime": 0.6558744215467591, + "beatIndex": 74, + "phraseIndex": 9, + "nearestBeatSec": 39.643, + "offsetMs": -242, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 42.052, + "relativeTime": 0.7000033292272864, + "beatIndex": 78, + "phraseIndex": 9, + "nearestBeatSec": 41.786, + "offsetMs": 266, + "segmentRole": "climax", + "strength": "medium", + "description": "核心协作特性集中呈现" + }, + { + "eventType": "cut", + "timeSec": 44.303, + "relativeTime": 0.73747378233512, + "beatIndex": 83, + "phraseIndex": 10, + "nearestBeatSec": 44.464, + "offsetMs": -161, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 45.869, + "relativeTime": 0.7635416319872158, + "beatIndex": 86, + "phraseIndex": 10, + "nearestBeatSec": 46.071, + "offsetMs": -202, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 47.036, + "relativeTime": 0.7829676732030496, + "beatIndex": 88, + "phraseIndex": 11, + "nearestBeatSec": 47.143, + "offsetMs": -107, + "strength": "medium", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 49.003, + "relativeTime": 0.8157106235642707, + "beatIndex": 91, + "phraseIndex": 11, + "nearestBeatSec": 48.75, + "offsetMs": 253, + "strength": "medium", + "description": "样例第 12 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 50.903, + "relativeTime": 0.8473382827845657, + "beatIndex": 95, + "phraseIndex": 11, + "nearestBeatSec": 50.893, + "offsetMs": 10, + "strength": "medium", + "description": "样例第 13 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 54.067, + "relativeTime": 0.9000066584545727, + "beatIndex": 101, + "phraseIndex": 12, + "nearestBeatSec": 54.107, + "offsetMs": -40, + "segmentRole": "closing", + "strength": "medium", + "description": "品牌标识落版" + }, + { + "eventType": "cut", + "timeSec": 54.203, + "relativeTime": 0.9022705330092886, + "beatIndex": 101, + "phraseIndex": 12, + "nearestBeatSec": 54.107, + "offsetMs": 96, + "strength": "medium", + "description": "样例第 14 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 58.436, + "relativeTime": 0.9727336285248194, + "beatIndex": 109, + "phraseIndex": 13, + "nearestBeatSec": 58.393, + "offsetMs": 43, + "strength": "strong", + "description": "样例第 15 个切镜点" + } + ], + "cutIntervalsSec": [ + 2.3, + 3, + 5.667, + 6.466, + 6.2, + 4.3, + 5.034, + 6.434, + 4.902, + 1.566, + 1.167, + 1.967, + 1.9, + 3.3, + 4.233, + 1.638 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:16 镜,平均 3.8s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_4e2adf18-611e-47c1-9da4-2c9d3d25e4be", + "source": "global_sample", + "durationSec": 60.074, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 51 个,硬切 15 个。", + "真实音频 onset 18 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 4, + "relativeTime": 0.067, + "strength": "strong", + "energyDb": -7.109 + }, + { + "timeSec": 14.529, + "relativeTime": 0.242, + "strength": "strong", + "energyDb": -8.171 + }, + { + "timeSec": 16.029, + "relativeTime": 0.267, + "strength": "medium", + "energyDb": -8.378 + }, + { + "timeSec": 23.018, + "relativeTime": 0.383, + "strength": "weak", + "energyDb": -9.113 + }, + { + "timeSec": 30.004, + "relativeTime": 0.499, + "strength": "medium", + "energyDb": -8.365 + }, + { + "timeSec": 31.521, + "relativeTime": 0.525, + "strength": "weak", + "energyDb": -8.932 + }, + { + "timeSec": 35.521, + "relativeTime": 0.591, + "strength": "weak", + "energyDb": -8.873 + }, + { + "timeSec": 37.021, + "relativeTime": 0.616, + "strength": "strong", + "energyDb": -7.174 + }, + { + "timeSec": 38.521, + "relativeTime": 0.641, + "strength": "strong", + "energyDb": -7.524 + }, + { + "timeSec": 41.974, + "relativeTime": 0.699, + "strength": "strong", + "energyDb": -8.225 + }, + { + "timeSec": 43.474, + "relativeTime": 0.724, + "strength": "strong", + "energyDb": -8.126 + }, + { + "timeSec": 45.474, + "relativeTime": 0.757, + "strength": "strong", + "energyDb": -7.344 + }, + { + "timeSec": 47.487, + "relativeTime": 0.79, + "strength": "strong", + "energyDb": -6.68 + }, + { + "timeSec": 48.987, + "relativeTime": 0.815, + "strength": "strong", + "energyDb": -7.084 + }, + { + "timeSec": 50.981, + "relativeTime": 0.849, + "strength": "medium", + "energyDb": -8.503 + }, + { + "timeSec": 52.981, + "relativeTime": 0.882, + "strength": "medium", + "energyDb": -8.872 + }, + { + "timeSec": 53.981, + "relativeTime": 0.899, + "strength": "strong", + "energyDb": -7.125 + }, + { + "timeSec": 55.481, + "relativeTime": 0.924, + "strength": "strong", + "energyDb": -7.746 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 3.733, + "relativeTime": 0.062, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 4, + "relativeTime": 0.067, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 4.133, + "relativeTime": 0.069, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 4, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.467, + "relativeTime": 0.074, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.8, + "relativeTime": 0.08, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.133, + "relativeTime": 0.085, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 7.3, + "relativeTime": 0.122, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 8.767, + "relativeTime": 0.146, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 9.1, + "relativeTime": 0.151, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 9.433, + "relativeTime": 0.157, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 9.767, + "relativeTime": 0.163, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 10.1, + "relativeTime": 0.168, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 11.267, + "relativeTime": 0.188, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 12.6, + "relativeTime": 0.21, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 13.6, + "relativeTime": 0.226, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 14.1, + "relativeTime": 0.235, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 14.433, + "relativeTime": 0.24, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 14.529, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 14.529, + "relativeTime": 0.242, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 16.029, + "relativeTime": 0.267, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 23.018, + "relativeTime": 0.383, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 30.004, + "relativeTime": 0.499, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 31.521, + "relativeTime": 0.525, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 35.521, + "relativeTime": 0.591, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 37.021, + "relativeTime": 0.616, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 38.521, + "relativeTime": 0.641, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 41.974, + "relativeTime": 0.699, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 43.474, + "relativeTime": 0.724, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 45.474, + "relativeTime": 0.757, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "reveal_pan", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "抽象视觉开场 -> 风格铺垫过渡 -> 多场景界面展示 -> 核心协作特性集中呈现 -> 品牌标识落版", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "抽象视觉开场", + "durationRatio": 0.1, + "intent": "第一时间用辨识度高的动态视觉抓住观众注意力,建立统一风格认知", + "copyPattern": "标志性抽象图形悬念开场", + "watchingPurpose": "开场抓停:第一时间用辨识度高的动态视觉抓住观众注意力,建立统一风格认知" + }, + { + "role": "setup", + "label": "风格铺垫过渡", + "durationRatio": 0.2, + "intent": "通过系列抽象动态元素铺垫品牌专属的渐变视觉体系,引导观众进入展示语境", + "copyPattern": "统一视觉符号递进铺垫", + "watchingPurpose": "建立背景:通过系列抽象动态元素铺垫品牌专属的渐变视觉体系,引导观众进入展示语境" + }, + { + "role": "develop", + "label": "多场景界面展示", + "durationRatio": 0.4, + "intent": "依次呈现不同办公设备、不同使用场景下的产品界面,传递功能覆盖广度", + "copyPattern": "多场景功能逐一铺陈", + "watchingPurpose": "推进主体:依次呈现不同办公设备、不同使用场景下的产品界面,传递功能覆盖广度" + }, + { + "role": "climax", + "label": "核心协作特性集中呈现", + "durationRatio": 0.2, + "intent": "高密度展示多窗口、多用户协同的办公界面,突出产品核心协作价值", + "copyPattern": "核心价值密集输出", + "watchingPurpose": "放大重点:高密度展示多窗口、多用户协同的办公界面,突出产品核心协作价值" + }, + { + "role": "closing", + "label": "品牌标识落版", + "durationRatio": 0.1, + "intent": "回归品牌核心视觉符号,强化观众对品牌的最终记忆点", + "copyPattern": "品牌符号闭环收尾", + "watchingPurpose": "收束记忆点:回归品牌核心视觉符号,强化观众对品牌的最终记忆点" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 60.074, + "shotCount": 16, + "avgShotSec": 3.75, + "cutDensity": "medium", + "peakAt": 0.75, + "beatHints": [ + "轻电子音渐入", + "转场卡点音效", + "节奏逐步上扬", + "品牌落版重音" + ], + "rhythmNotes": [ + "平均 3.8s/镜,整体为 中等节奏。", + "高潮位置约在全片 75%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「无显性标题栏,全视觉流叙事」协同", + "animation": "字幕/标题可能配合「流体动态无缝转场」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "无显性标题栏,全视觉流叙事", + "stickerUsage": "无额外装饰贴纸,统一使用品牌专属渐变几何元素", + "coverStyle": "核心抽象品牌图形+品牌专属紫蓝渐变背景主视觉", + "overlayStyle": "无显性标题栏,全视觉流叙事 / 无额外装饰贴纸,统一使用品牌专属渐变几何元素", + "notes": [ + "画面包装迁移重点:无显性标题栏,全视觉流叙事;无额外装饰贴纸,统一使用品牌专属渐变几何元素;核心抽象品牌图形+品牌专属紫蓝渐变背景主视觉", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "流体动态无缝转场", + "frequency": "中等频率切换", + "notableTransitions": [ + "流体动态无缝转场" + ], + "executableTechniques": [ + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻电子音渐入", + "转场卡点音效", + "节奏逐步上扬", + "品牌落版重音" + ], + "syncStrategy": "参考蓝图中的 4 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "s1", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 6, + "optional": false + }, + { + "slotId": "s2", + "segmentRole": "setup", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 12, + "optional": false + }, + { + "slotId": "s3", + "segmentRole": "develop", + "requiredAssetTypes": [ + "usage_demo", + "b_roll" + ], + "minDurationSec": 24, + "optional": false + }, + { + "slotId": "s4", + "segmentRole": "climax", + "requiredAssetTypes": [ + "usage_demo", + "product_closeup" + ], + "minDurationSec": 12, + "optional": false + }, + { + "slotId": "s5", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card", + "b_roll" + ], + "minDurationSec": 6, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 60.074s", + "ref": "seed:v0200fg10000c615tsbc77u3e5mi9m50.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 29.999fps" + }, + { + "type": "scene_cut", + "detail": "16 个镜头 / 15 个切点(原始 15 个,已合并 <0.4s 密集检测)", + "ref": "2.30, 5.30, 10.97, 17.43, 23.63, 27.93, 32.97, 39.40, 44.30, 45.87, 47.04, 49.00, 50.90, 54.20, 58.44" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 medium;模板事件 28 个;音频 onset 18 个", + "ref": "letterbox_frame, reveal_pan, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该品牌展示类视频以高辨识度的抽象动态视觉开场快速抓取观众注意力,通过统一风格的过渡元素铺垫品牌专属视觉体系,降低观众认知门槛;中段逐步铺陈多场景产品使用画面,让观众直观感知产品覆盖的办公场景广度;高潮部分高密度输出核心协作功能细节,强化产品核心价值感知;最终落版品牌标识完成视觉闭环,全程节奏匹配轻电子BGM卡点,无冗余信息,给观众留下统一深刻的品牌印象。", + "createdAt": "2026-06-08T10:00:10.164Z", + "updatedAt": "2026-06-08T10:02:06.132Z" + }, + { + "id": "pattern_29ef8eb6", + "scope": "global", + "sourceSampleId": "5c9bc806-824b-469d-918b-88015bf6ca41", + "name": "v0200fg10000d7korqnog65phf4bcgtg · 展示模式", + "summary": "该样例是 6s 的 展示 视频,结构为 暗调悬念开场 -> 核心主题展示 -> 品牌标识收尾,节奏为 慢节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "low", + "hook", + "develop", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "generic_next_action", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "weak", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": false, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:暗调悬念开场 -> 品牌标识收尾", + "formula": "暗调悬念开场 -> 核心主题展示 -> 品牌标识收尾", + "source": { + "filename": "v0200fg10000d7korqnog65phf4bcgtg.mov", + "durationSec": 5.785, + "aspectRatio": "720:456", + "shotCount": 1 + }, + "segments": [ + { + "role": "hook", + "label": "暗调悬念开场", + "durationRatio": 0.3, + "intent": "用低亮度近乎全黑的特殊画面第一时间抓取观众注意力,引发好奇", + "copyPattern": "暗调留白悬念开场,仅露出极少量视觉信息降低信息密度", + "watchingPurpose": "开场抓停:用低亮度近乎全黑的特殊画面第一时间抓取观众注意力,引发好奇" + }, + { + "role": "develop", + "label": "核心主题展示", + "durationRatio": 0.5, + "intent": "清晰传递本次内容的核心主题概念,强化氛围感", + "copyPattern": "核心主题slogan静态展示,搭配统一暗调颗粒质感烘托氛围", + "watchingPurpose": "推进主体:清晰传递本次内容的核心主题概念,强化氛围感" + }, + { + "role": "closing", + "label": "品牌标识收尾", + "durationRatio": 0.2, + "intent": "完成品牌信息的弱触达,形成内容闭环", + "copyPattern": "低存在感品牌标识收尾,不破坏整体氛围感", + "watchingPurpose": "收束记忆点:完成品牌信息的弱触达,形成内容闭环" + } + ], + "pacing": { + "durationSec": 5.785, + "shotCount": 1, + "avgShotSec": 5.79, + "cutDensity": "low", + "peakAt": 0.6, + "beatHints": [ + "低缓氛围感白噪音铺垫", + "结尾轻脆提示音收尾" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "无额外独立标题栏,主题文字直接叠加在画面原生暗调背景上", + "stickerUsage": "无任何额外装饰贴纸,仅保留画面原生的颗粒噪点特效", + "transitionStyle": "全程无转场效果,单镜头静态呈现", + "coverStyle": "暗调带核心主题文字的全帧画面,突出氛围感与神秘感" + }, + "editingTechniques": [ + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "无额外独立标题栏,主题文字直接叠加在画面原生暗调背景上", + "stickerUsage": "无任何额外装饰贴纸,仅保留画面原生的颗粒噪点特效", + "coverStyle": "暗调带核心主题文字的全帧画面,突出氛围感与神秘感", + "overlayStyle": "无额外独立标题栏,主题文字直接叠加在画面原生暗调背景上 / 无任何额外装饰贴纸,仅保留画面原生的颗粒噪点特效", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 5.785, + "bpm": 92, + "beatCount": 9, + "beatStability": 0.45, + "onsetDensity": 0.17286084701815038, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217 + ], + "phraseBoundariesSec": [ + 0, + 5.217 + ], + "sections": [ + { + "startSec": 0, + "endSec": 1.2, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 1.2, + "endSec": 3.182, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609 + ] + }, + { + "startSec": 3.182, + "endSec": 4.382, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [] + }, + { + "startSec": 4.382, + "endSec": 5.785, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 5.217 + ] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_5c9bc806-824b-469d-918b-88015bf6ca41", + "source": "global_sample", + "durationSec": 5.785, + "music": { + "hasAudio": true, + "durationSec": 5.785, + "bpm": 92, + "beatCount": 9, + "beatStability": 0.45, + "onsetDensity": 0.17286084701815038, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217 + ], + "phraseBoundariesSec": [ + 0, + 5.217 + ], + "sections": [ + { + "startSec": 0, + "endSec": 1.2, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 1.2, + "endSec": 3.182, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609 + ] + }, + { + "startSec": 3.182, + "endSec": 4.382, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [] + }, + { + "startSec": 4.382, + "endSec": 5.785, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 5.217 + ] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 1, + "avgShotSec": 5.79, + "peakAt": 0.6, + "cutEveryBeats": 8.87, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "暗调悬念开场" + }, + { + "eventType": "caption", + "timeSec": 1.736, + "relativeTime": 0.3000864304235091, + "beatIndex": 3, + "phraseIndex": 0, + "nearestBeatSec": 1.957, + "offsetMs": -221, + "segmentRole": "develop", + "strength": "medium", + "description": "核心主题展示" + }, + { + "eventType": "caption", + "timeSec": 4.628, + "relativeTime": 0.8, + "beatIndex": 7, + "phraseIndex": 0, + "nearestBeatSec": 4.565, + "offsetMs": 63, + "segmentRole": "closing", + "strength": "medium", + "description": "品牌标识收尾" + } + ], + "cutIntervalsSec": [ + 5.785 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:1 镜,平均 5.8s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_5c9bc806-824b-469d-918b-88015bf6ca41", + "source": "global_sample", + "durationSec": 5.785, + "sourceAspect": "720:456", + "targetCanvasAspect": "9:16", + "layoutPreset": "camera_carousel", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": true, + "borderColor": "rgba(255,255,255,0.12)", + "labelStyle": "camera_ui", + "viewport": { + "aspectRatio": "592:288", + "x": 0.089, + "y": 0.254, + "width": 0.822, + "height": 0.632 + } + }, + "motionLanguage": { + "internalMotionIntensity": "high", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "snap_cut", + "notes": [ + "低阈值画面变化 15 个,硬切 0 个。", + "真实音频 onset 3 个。", + "样例更像固定相机外壳内的横向 carousel 滑动模板。" + ] + }, + "audioOnsets": [ + { + "timeSec": 1.5, + "relativeTime": 0.259, + "strength": "medium", + "energyDb": -9.767 + }, + { + "timeSec": 3, + "relativeTime": 0.519, + "strength": "strong", + "energyDb": -8.782 + }, + { + "timeSec": 4, + "relativeTime": 0.691, + "strength": "strong", + "energyDb": -8.816 + } + ], + "events": [ + { + "kind": "carousel_slide", + "timeSec": 0.786, + "relativeTime": 0.136, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 1.153, + "relativeTime": 0.199, + "strength": "medium", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 1.486, + "relativeTime": 0.257, + "strength": "medium", + "direction": "left", + "nearestOnsetSec": 1.5, + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "audio_onset", + "timeSec": 1.5, + "relativeTime": 0.259, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "carousel_slide", + "timeSec": 1.82, + "relativeTime": 0.315, + "strength": "medium", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 2.153, + "relativeTime": 0.372, + "strength": "medium", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 2.486, + "relativeTime": 0.43, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 2.82, + "relativeTime": 0.487, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 3, + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "audio_onset", + "timeSec": 3, + "relativeTime": 0.519, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "carousel_slide", + "timeSec": 3.17, + "relativeTime": 0.548, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 3, + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 3.52, + "relativeTime": 0.608, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 3.853, + "relativeTime": 0.666, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 4, + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "audio_onset", + "timeSec": 4, + "relativeTime": 0.691, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "carousel_slide", + "timeSec": 4.186, + "relativeTime": 0.724, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 4, + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 4.52, + "relativeTime": 0.781, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 4.853, + "relativeTime": 0.839, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 5.203, + "relativeTime": 0.899, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + }, + { + "kind": "carousel_slide", + "timeSec": 5.536, + "relativeTime": 0.957, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为固定相机外壳内的横向素材滑动" + } + ], + "strategySummary": "迁移时保留固定相机/胶片外壳,把多段新素材拼成横向 carousel strip,并按真实音频 onset 推进滑动。", + "renderHints": [ + "camera_carousel", + "reveal_pan", + "snap_cut", + "carousel_strip", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "暗调悬念开场 -> 核心主题展示 -> 品牌标识收尾", + "segmentCount": 3, + "segments": [ + { + "role": "hook", + "label": "暗调悬念开场", + "durationRatio": 0.3, + "intent": "用低亮度近乎全黑的特殊画面第一时间抓取观众注意力,引发好奇", + "copyPattern": "暗调留白悬念开场,仅露出极少量视觉信息降低信息密度", + "watchingPurpose": "开场抓停:用低亮度近乎全黑的特殊画面第一时间抓取观众注意力,引发好奇" + }, + { + "role": "develop", + "label": "核心主题展示", + "durationRatio": 0.5, + "intent": "清晰传递本次内容的核心主题概念,强化氛围感", + "copyPattern": "核心主题slogan静态展示,搭配统一暗调颗粒质感烘托氛围", + "watchingPurpose": "推进主体:清晰传递本次内容的核心主题概念,强化氛围感" + }, + { + "role": "closing", + "label": "品牌标识收尾", + "durationRatio": 0.2, + "intent": "完成品牌信息的弱触达,形成内容闭环", + "copyPattern": "低存在感品牌标识收尾,不破坏整体氛围感", + "watchingPurpose": "收束记忆点:完成品牌信息的弱触达,形成内容闭环" + } + ], + "notes": [ + "脚本结构由 3 个段落组成,按 hook -> develop -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 5.785, + "shotCount": 1, + "avgShotSec": 5.79, + "cutDensity": "low", + "peakAt": 0.6, + "beatHints": [ + "低缓氛围感白噪音铺垫", + "结尾轻脆提示音收尾" + ], + "rhythmNotes": [ + "平均 5.8s/镜,整体为 慢节奏。", + "高潮位置约在全片 60%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「无额外独立标题栏,主题文字直接叠加在画面原生暗调背景上」协同", + "animation": "字幕/标题可能配合「全程无转场效果,单镜头静态呈现」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "无额外独立标题栏,主题文字直接叠加在画面原生暗调背景上", + "stickerUsage": "无任何额外装饰贴纸,仅保留画面原生的颗粒噪点特效", + "coverStyle": "暗调带核心主题文字的全帧画面,突出氛围感与神秘感", + "overlayStyle": "无额外独立标题栏,主题文字直接叠加在画面原生暗调背景上 / 无任何额外装饰贴纸,仅保留画面原生的颗粒噪点特效", + "notes": [ + "画面包装迁移重点:无额外独立标题栏,主题文字直接叠加在画面原生暗调背景上;无任何额外装饰贴纸,仅保留画面原生的颗粒噪点特效;暗调带核心主题文字的全帧画面,突出氛围感与神秘感", + "模板画幅:camera_carousel,viewport=592:288,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "全程无转场效果,单镜头静态呈现", + "frequency": "低频切换", + "notableTransitions": [ + "全程无转场效果,单镜头静态呈现" + ], + "executableTechniques": [ + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "低缓氛围感白噪音铺垫", + "结尾轻脆提示音收尾" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 1.7, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "text_card", + "b_roll" + ], + "minDurationSec": 2.9, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 1.1, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 5.785s", + "ref": "seed:v0200fg10000d7korqnog65phf4bcgtg.mov" + }, + { + "type": "resolution", + "detail": "720x456 @ 59.278fps" + }, + { + "type": "scene_cut", + "detail": "1 个镜头 / 0 个切点(原始 0 个,已合并 <0.4s 密集检测)", + "ref": "" + }, + { + "type": "template_profile", + "detail": "camera_carousel;内部运动 high;模板事件 18 个;音频 onset 3 个", + "ref": "camera_carousel, reveal_pan, snap_cut, carousel_strip, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 1 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "适配短视频信息流快节奏抓眼需求,用非常规的暗调低亮度画面开场快速抓住刷内容的观众注意力,避免常规亮屏内容的审美疲劳;中段直接展示核心主题slogan,在极短时长内完成核心信息传递;最后弱展示品牌标识形成内容闭环,全程无多余视觉元素干扰,最大化强化整体氛围感,适配品牌节日/焕新主题的短宣发场景。", + "createdAt": "2026-06-08T10:02:09.963Z", + "updatedAt": "2026-06-08T10:02:11.523Z" + }, + { + "id": "pattern_fd6c1fed", + "scope": "global", + "sourceSampleId": "258052e9-48fa-40dd-ba73-561b3be47cf7", + "name": "v0200fg10000d89l837og65h2bin2v1g · 展示模式", + "summary": "该样例是 16s 的 展示 视频,结构为 全造型穿搭直观展示 -> 平台引流行动指引,节奏为 慢节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "low", + "develop", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "not_recommended", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": false, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:全造型穿搭直观展示 -> 平台引流行动指引", + "formula": "全造型穿搭直观展示 -> 平台引流行动指引", + "source": { + "filename": "v0200fg10000d89l837og65h2bin2v1g.MP4", + "durationSec": 16.224, + "aspectRatio": "720:1080", + "shotCount": 2 + }, + "segments": [ + { + "role": "develop", + "label": "全造型穿搭直观展示", + "durationRatio": 0.81, + "intent": "无冗余铺垫直接输出整套复古休闲穿搭的视觉亮点,让观众完整浏览所有搭配细节,精准抓住穿搭内容受众注意力", + "copyPattern": "全景式全造型静态展示,搭配人物友好互动动作降低距离感", + "watchingPurpose": "推进主体:无冗余铺垫直接输出整套复古休闲穿搭的视觉亮点,让观众完整浏览所有搭配细节,精准抓住穿搭内容受众注意力" + }, + { + "role": "closing", + "label": "平台引流行动指引", + "durationRatio": 0.19, + "intent": "清晰告知观众后续获取更多相关内容的路径,完成站外引流闭环", + "copyPattern": "明确给出平台名称+精准搜索关键词,配套扫码/截图操作提示降低用户行动门槛", + "watchingPurpose": "收束记忆点:清晰告知观众后续获取更多相关内容的路径,完成站外引流闭环" + } + ], + "pacing": { + "durationSec": 16.224, + "shotCount": 2, + "avgShotSec": 8.11, + "cutDensity": "low", + "peakAt": 0.75, + "beatHints": [ + "轻缓松弛的日系穿搭类BGM", + "跳转引导页时搭配短促卡点提示音" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "左上角悬浮窄条展示平台logo、账号ID与用户名", + "stickerUsage": "仅添加1个模拟摄像头的趣味小装饰图标,无多余冗余贴纸", + "transitionStyle": "无特效硬切跳转,节奏干净利落", + "coverStyle": "人物全造型展示+账号标识的竖版穿搭封面" + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "左上角悬浮窄条展示平台logo、账号ID与用户名", + "stickerUsage": "仅添加1个模拟摄像头的趣味小装饰图标,无多余冗余贴纸", + "coverStyle": "人物全造型展示+账号标识的竖版穿搭封面", + "overlayStyle": "左上角悬浮窄条展示平台logo、账号ID与用户名 / 仅添加1个模拟摄像头的趣味小装饰图标,无多余冗余贴纸", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 16.224, + "bpm": 92, + "beatCount": 25, + "beatStability": 0.6146851557172085, + "onsetDensity": 0.1232741617357002, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435, + 13.043, + 15.652 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435, + 15.652 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.92, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.609 + ] + }, + { + "startSec": 2.92, + "endSec": 11.032, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 5.217, + 7.826, + 10.435 + ] + }, + { + "startSec": 11.032, + "endSec": 13.952, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 13.043 + ] + }, + { + "startSec": 13.952, + "endSec": 16.224, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 15.652 + ] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_258052e9-48fa-40dd-ba73-561b3be47cf7", + "source": "global_sample", + "durationSec": 16.224, + "music": { + "hasAudio": true, + "durationSec": 16.224, + "bpm": 92, + "beatCount": 25, + "beatStability": 0.6146851557172085, + "onsetDensity": 0.1232741617357002, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435, + 13.043, + 15.652 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435, + 15.652 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.92, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.609 + ] + }, + { + "startSec": 2.92, + "endSec": 11.032, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 5.217, + 7.826, + 10.435 + ] + }, + { + "startSec": 11.032, + "endSec": 13.952, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 13.043 + ] + }, + { + "startSec": 13.952, + "endSec": 16.224, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 15.652 + ] + } + ], + "tags": [ + "showcase", + "density:low", + "bpm:92", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 2, + "avgShotSec": 8.11, + "peakAt": 0.75, + "cutEveryBeats": 20.235, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "develop", + "strength": "strong", + "description": "全造型穿搭直观展示" + }, + { + "eventType": "caption", + "timeSec": 13.141, + "relativeTime": 0.8099728796844181, + "beatIndex": 20, + "phraseIndex": 2, + "nearestBeatSec": 13.043, + "offsetMs": 98, + "segmentRole": "closing", + "strength": "medium", + "description": "平台引流行动指引" + }, + { + "eventType": "cut", + "timeSec": 13.197, + "relativeTime": 0.8134245562130177, + "beatIndex": 20, + "phraseIndex": 2, + "nearestBeatSec": 13.043, + "offsetMs": 154, + "strength": "strong", + "description": "样例第 1 个切镜点" + } + ], + "cutIntervalsSec": [ + 13.197, + 3.027 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:2 镜,平均 8.1s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_258052e9-48fa-40dd-ba73-561b3be47cf7", + "source": "global_sample", + "durationSec": 16.224, + "sourceAspect": "720:1080", + "targetCanvasAspect": "9:16", + "layoutPreset": "full_bleed", + "frameStyle": { + "backgroundColor": "#050505", + "matte": false, + "roundedMask": false, + "labelStyle": "none", + "viewport": { + "aspectRatio": "720:1072", + "x": 0, + "y": 0.004, + "width": 1, + "height": 0.993 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": true, + "hasViewportSlides": false, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 4 个,硬切 1 个。", + "真实音频 onset 5 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 5, + "relativeTime": 0.308, + "strength": "medium", + "energyDb": -13.288 + }, + { + "timeSec": 6, + "relativeTime": 0.37, + "strength": "strong", + "energyDb": -11.658 + }, + { + "timeSec": 8, + "relativeTime": 0.493, + "strength": "strong", + "energyDb": -11.659 + }, + { + "timeSec": 10.5, + "relativeTime": 0.647, + "strength": "strong", + "energyDb": -11.735 + }, + { + "timeSec": 12.5, + "relativeTime": 0.77, + "strength": "medium", + "energyDb": -12.233 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 4.454, + "relativeTime": 0.275, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 5, + "relativeTime": 0.308, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 6, + "relativeTime": 0.37, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 6.54, + "relativeTime": 0.403, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 8, + "relativeTime": 0.493, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 8.625, + "relativeTime": 0.532, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 10.5, + "relativeTime": 0.647, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 10.711, + "relativeTime": 0.66, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 12.5, + "relativeTime": 0.77, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。", + "renderHints": [ + "full_bleed", + "ken_burns_in", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "全造型穿搭直观展示 -> 平台引流行动指引", + "segmentCount": 2, + "segments": [ + { + "role": "develop", + "label": "全造型穿搭直观展示", + "durationRatio": 0.81, + "intent": "无冗余铺垫直接输出整套复古休闲穿搭的视觉亮点,让观众完整浏览所有搭配细节,精准抓住穿搭内容受众注意力", + "copyPattern": "全景式全造型静态展示,搭配人物友好互动动作降低距离感", + "watchingPurpose": "推进主体:无冗余铺垫直接输出整套复古休闲穿搭的视觉亮点,让观众完整浏览所有搭配细节,精准抓住穿搭内容受众注意力" + }, + { + "role": "closing", + "label": "平台引流行动指引", + "durationRatio": 0.19, + "intent": "清晰告知观众后续获取更多相关内容的路径,完成站外引流闭环", + "copyPattern": "明确给出平台名称+精准搜索关键词,配套扫码/截图操作提示降低用户行动门槛", + "watchingPurpose": "收束记忆点:清晰告知观众后续获取更多相关内容的路径,完成站外引流闭环" + } + ], + "notes": [ + "脚本结构由 2 个段落组成,按 develop -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 16.224, + "shotCount": 2, + "avgShotSec": 8.11, + "cutDensity": "low", + "peakAt": 0.75, + "beatHints": [ + "轻缓松弛的日系穿搭类BGM", + "跳转引导页时搭配短促卡点提示音" + ], + "rhythmNotes": [ + "平均 8.1s/镜,整体为 慢节奏。", + "高潮位置约在全片 75%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「左上角悬浮窄条展示平台logo、账号ID与用户名」协同", + "animation": "字幕/标题可能配合「无特效硬切跳转,节奏干净利落」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "左上角悬浮窄条展示平台logo、账号ID与用户名", + "stickerUsage": "仅添加1个模拟摄像头的趣味小装饰图标,无多余冗余贴纸", + "coverStyle": "人物全造型展示+账号标识的竖版穿搭封面", + "overlayStyle": "左上角悬浮窄条展示平台logo、账号ID与用户名 / 仅添加1个模拟摄像头的趣味小装饰图标,无多余冗余贴纸", + "notes": [ + "画面包装迁移重点:左上角悬浮窄条展示平台logo、账号ID与用户名;仅添加1个模拟摄像头的趣味小装饰图标,无多余冗余贴纸;人物全造型展示+账号标识的竖版穿搭封面", + "模板画幅:full_bleed,viewport=720:1072,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "无特效硬切跳转,节奏干净利落", + "frequency": "低频切换", + "notableTransitions": [ + "无特效硬切跳转,节奏干净利落" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻缓松弛的日系穿搭类BGM", + "跳转引导页时搭配短促卡点提示音" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "SLOT-001", + "segmentRole": "develop", + "requiredAssetTypes": [ + "talking_head", + "b_roll" + ], + "minDurationSec": 12, + "optional": false + }, + { + "slotId": "SLOT-002", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 16.224s", + "ref": "seed:v0200fg10000d89l837og65h2bin2v1g.MP4" + }, + { + "type": "resolution", + "detail": "720x1080 @ 54.553fps" + }, + { + "type": "scene_cut", + "detail": "2 个镜头 / 1 个切点(原始 1 个,已合并 <0.4s 密集检测)", + "ref": "13.20" + }, + { + "type": "template_profile", + "detail": "full_bleed;内部运动 low;模板事件 9 个;音频 onset 5 个", + "ref": "full_bleed, ken_burns_in, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 2 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构适配穿搭展示类内容的受众观看习惯,开场无冗余铺垫直接输出高辨识度的完整造型,第一时间抓住穿搭爱好者注意力;低剪辑密度的长镜头给观众足够时间浏览整套搭配的所有细节,避免快剪导致的造型信息遗漏;最后直接跳转清晰的引流指引页,明确给出搜索关键词和操作提示,大幅降低用户后续获取更多内容的行动成本,整体节奏松弛舒适,完全服务于穿搭展示的核心目标。", + "createdAt": "2026-06-08T10:11:34.082Z", + "updatedAt": "2026-06-08T10:11:35.267Z" + }, + { + "id": "pattern_73460c4e", + "scope": "global", + "sourceSampleId": "18a085c2-9b89-44b9-b2b3-4194ae0819ae", + "name": "v0200fg10000d826j7vog65r4gii35u0 · 带货模式", + "summary": "该样例是 14s 的 带货 视频,结构为 联名产品全景陈列开场 -> 饮品全流程调配演示 -> 成品细节多角度展示 -> 团购路径引导,节奏为 快节奏。", + "videoGenre": "product", + "tags": [ + "product", + "high", + "hook", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "thin_pattern", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "这条样例故事结构偏薄,建议作为 secondary story 或剪辑参考。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "带货:联名产品全景陈列开场 -> 团购路径引导", + "formula": "联名产品全景陈列开场 -> 饮品全流程调配演示 -> 成品细节多角度展示 -> 团购路径引导", + "source": { + "filename": "v0200fg10000d826j7vog65r4gii35u0.MP4", + "durationSec": 14.395, + "aspectRatio": "720:960", + "shotCount": 12 + }, + "segments": [ + { + "role": "hook", + "label": "联名产品全景陈列开场", + "durationRatio": 0.1, + "intent": "快速用高颜值联名周边+成品组合抓住目标年轻用户注意力", + "copyPattern": "限定款产品全景视觉冲击", + "watchingPurpose": "开场抓停:快速用高颜值联名周边+成品组合抓住目标年轻用户注意力" + }, + { + "role": "develop", + "label": "饮品全流程调配演示", + "durationRatio": 0.5, + "intent": "清晰展示饮品从基底到加料的完整制作步骤,强化用料真实感", + "copyPattern": "分步拆解制作工序,每一步动作特写呈现", + "watchingPurpose": "推进主体:清晰展示饮品从基底到加料的完整制作步骤,强化用料真实感" + }, + { + "role": "climax", + "label": "成品细节多角度展示", + "durationRatio": 0.25, + "intent": "突出联名款的颜值和冰淇淋的诱人质感,激发用户食欲", + "copyPattern": "多维度特写产品核心卖点,视觉放大食欲点", + "watchingPurpose": "放大重点:突出联名款的颜值和冰淇淋的诱人质感,激发用户食欲" + }, + { + "role": "closing", + "label": "团购路径引导", + "durationRatio": 0.15, + "intent": "明确告知用户购买渠道,降低行动门槛", + "copyPattern": "直接给出搜索关键词+操作指引", + "watchingPurpose": "收束记忆点:明确告知用户购买渠道,降低行动门槛" + } + ], + "pacing": { + "durationSec": 14.395, + "shotCount": 12, + "avgShotSec": 1.2, + "cutDensity": "high", + "peakAt": 0.65, + "beatHints": [ + "轻快甜品向BGM卡点每一次倾倒/放置动作", + "高潮段BGM节奏小幅上扬烘托食欲感", + "收尾引导段BGM放缓突出引导文字" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "顶部窄条放置联名款产品名称", + "stickerUsage": "仅少量添加卡通联名元素贴纸点缀不遮挡产品主体", + "transitionStyle": "快切为主+收尾段淡入淡出", + "coverStyle": "选多杯联名饮品全景图配醒目限定款文字" + }, + "storySkeleton": { + "arcType": "带货 / hook -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "快速用高颜值联名周边+成品组合抓住目标年轻用户注意力", + "清晰展示饮品从基底到加料的完整制作步骤,强化用料真实感", + "突出联名款的颜值和冰淇淋的诱人质感,激发用户食欲", + "明确告知用户购买渠道,降低行动门槛" + ], + "hookStyle": "限定款产品全景视觉冲击", + "turnOrProofStyle": "分步拆解制作工序,每一步动作特写呈现", + "payoffStyle": "直接给出搜索关键词+操作指引", + "requiredStoryFunctions": [ + "opening_hook", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "product" + ], + "assetRequirements": [ + "b_roll", + "product_closeup", + "usage_demo", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=high 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "snap_cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "high", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_beat_pulse", + "name": "图片素材运镜:beat_pulse", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "beat_pulse", + "beatPlacement": "on estimated beat grid", + "intensity": "high", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=beat_pulse,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunction": [ + "opening_hook", + "transition", + "cta" + ], + "appliesToAssetType": [ + "text" + ], + "appliesToVisualCluster": [], + "beatPlacement": "phrase boundary or shot boundary", + "cardAnimationPreset": "soft_crossfade", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "remotion", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ], + "packagingPattern": { + "titleBarStyle": "顶部窄条放置联名款产品名称", + "stickerUsage": "仅少量添加卡通联名元素贴纸点缀不遮挡产品主体", + "coverStyle": "选多杯联名饮品全景图配醒目限定款文字", + "overlayStyle": "顶部窄条放置联名款产品名称 / 仅少量添加卡通联名元素贴纸点缀不遮挡产品主体", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "visual cuts align to dense beat grid", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 14.395, + "bpm": 128, + "beatCount": 31, + "beatStability": 0.7504166666666666, + "onsetDensity": 0.8336227856894756, + "energyShape": "rising", + "peakAt": 0.65, + "confidence": "medium", + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625, + 7.5, + 9.375, + 11.25, + 13.125 + ], + "phraseBoundariesSec": [ + 0, + 3.75, + 7.5, + 11.25 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.591, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 1.875 + ] + }, + { + "startSec": 2.591, + "endSec": 7.917, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 3.75, + 5.625, + 7.5 + ] + }, + { + "startSec": 7.917, + "endSec": 10.508, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 9.375 + ] + }, + { + "startSec": 10.508, + "endSec": 14.395, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 11.25, + 13.125 + ] + } + ], + "tags": [ + "product", + "density:high", + "bpm:128", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_18a085c2-9b89-44b9-b2b3-4194ae0819ae", + "source": "global_sample", + "durationSec": 14.395, + "music": { + "hasAudio": true, + "durationSec": 14.395, + "bpm": 128, + "beatCount": 31, + "beatStability": 0.7504166666666666, + "onsetDensity": 0.8336227856894756, + "energyShape": "rising", + "peakAt": 0.65, + "confidence": "medium", + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625, + 7.5, + 9.375, + 11.25, + 13.125 + ], + "phraseBoundariesSec": [ + 0, + 3.75, + 7.5, + 11.25 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.591, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 1.875 + ] + }, + { + "startSec": 2.591, + "endSec": 7.917, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 3.75, + 5.625, + 7.5 + ] + }, + { + "startSec": 7.917, + "endSec": 10.508, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 9.375 + ] + }, + { + "startSec": 10.508, + "endSec": 14.395, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 11.25, + 13.125 + ] + } + ], + "tags": [ + "product", + "density:high", + "bpm:128", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "high", + "shotCount": 12, + "avgShotSec": 1.2, + "peakAt": 0.65, + "cutEveryBeats": 2.133, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "联名产品全景陈列开场" + }, + { + "eventType": "cut", + "timeSec": 1, + "relativeTime": 0.06946856547412296, + "beatIndex": 2, + "phraseIndex": 0, + "nearestBeatSec": 0.938, + "offsetMs": 62, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 1.44, + "relativeTime": 0.10003473428273706, + "beatIndex": 3, + "phraseIndex": 0, + "nearestBeatSec": 1.406, + "offsetMs": 34, + "segmentRole": "develop", + "strength": "medium", + "description": "饮品全流程调配演示" + }, + { + "eventType": "cut", + "timeSec": 2, + "relativeTime": 0.13893713094824592, + "beatIndex": 4, + "phraseIndex": 0, + "nearestBeatSec": 1.875, + "offsetMs": 125, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 2.833, + "relativeTime": 0.19680444598819036, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 2.813, + "offsetMs": 20, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 3.9, + "relativeTime": 0.27092740534907955, + "beatIndex": 8, + "phraseIndex": 1, + "nearestBeatSec": 3.75, + "offsetMs": 150, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 4.9, + "relativeTime": 0.34039597082320255, + "beatIndex": 10, + "phraseIndex": 1, + "nearestBeatSec": 4.688, + "offsetMs": 212, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 6.5, + "relativeTime": 0.45154567558179926, + "beatIndex": 14, + "phraseIndex": 1, + "nearestBeatSec": 6.563, + "offsetMs": -63, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 7.5, + "relativeTime": 0.5210142410559222, + "beatIndex": 16, + "phraseIndex": 2, + "nearestBeatSec": 7.5, + "offsetMs": 0, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 8.367, + "relativeTime": 0.5812434873219868, + "beatIndex": 18, + "phraseIndex": 2, + "nearestBeatSec": 8.438, + "offsetMs": -71, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 8.637, + "relativeTime": 0.6000000000000001, + "beatIndex": 18, + "phraseIndex": 2, + "nearestBeatSec": 8.438, + "offsetMs": 199, + "segmentRole": "climax", + "strength": "medium", + "description": "成品细节多角度展示" + }, + { + "eventType": "cut", + "timeSec": 9.367, + "relativeTime": 0.6507120527961099, + "beatIndex": 20, + "phraseIndex": 2, + "nearestBeatSec": 9.375, + "offsetMs": -8, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 10.367, + "relativeTime": 0.7201806182702328, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 10.313, + "offsetMs": 54, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.367, + "relativeTime": 0.7896491837443558, + "beatIndex": 24, + "phraseIndex": 3, + "nearestBeatSec": 11.25, + "offsetMs": 117, + "strength": "strong", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 12.236, + "relativeTime": 0.8500173671413686, + "beatIndex": 26, + "phraseIndex": 3, + "nearestBeatSec": 12.188, + "offsetMs": 48, + "segmentRole": "closing", + "strength": "medium", + "description": "团购路径引导" + } + ], + "cutIntervalsSec": [ + 1, + 1, + 0.833, + 1.067, + 1, + 1.6, + 1, + 0.867, + 1, + 1, + 1, + 3.028 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:12 镜,平均 1.2s/镜,快节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_18a085c2-9b89-44b9-b2b3-4194ae0819ae", + "source": "global_sample", + "durationSec": 14.395, + "sourceAspect": "720:960", + "targetCanvasAspect": "9:16", + "layoutPreset": "full_bleed", + "frameStyle": { + "backgroundColor": "#050505", + "matte": false, + "roundedMask": false, + "labelStyle": "none", + "viewport": { + "aspectRatio": "720:960", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": false, + "hasViewportSlides": false, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 0 个,硬切 11 个。", + "真实音频 onset 3 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 1, + "relativeTime": 0.069, + "strength": "strong", + "energyDb": -13.762 + }, + { + "timeSec": 4.5, + "relativeTime": 0.313, + "strength": "strong", + "energyDb": -13.999 + }, + { + "timeSec": 8, + "relativeTime": 0.556, + "strength": "strong", + "energyDb": -13.903 + } + ], + "events": [ + { + "kind": "audio_onset", + "timeSec": 1, + "relativeTime": 0.069, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 4.5, + "relativeTime": 0.313, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 8, + "relativeTime": 0.556, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。", + "renderHints": [ + "full_bleed", + "ken_burns_in", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "联名产品全景陈列开场 -> 饮品全流程调配演示 -> 成品细节多角度展示 -> 团购路径引导", + "segmentCount": 4, + "segments": [ + { + "role": "hook", + "label": "联名产品全景陈列开场", + "durationRatio": 0.1, + "intent": "快速用高颜值联名周边+成品组合抓住目标年轻用户注意力", + "copyPattern": "限定款产品全景视觉冲击", + "watchingPurpose": "开场抓停:快速用高颜值联名周边+成品组合抓住目标年轻用户注意力" + }, + { + "role": "develop", + "label": "饮品全流程调配演示", + "durationRatio": 0.5, + "intent": "清晰展示饮品从基底到加料的完整制作步骤,强化用料真实感", + "copyPattern": "分步拆解制作工序,每一步动作特写呈现", + "watchingPurpose": "推进主体:清晰展示饮品从基底到加料的完整制作步骤,强化用料真实感" + }, + { + "role": "climax", + "label": "成品细节多角度展示", + "durationRatio": 0.25, + "intent": "突出联名款的颜值和冰淇淋的诱人质感,激发用户食欲", + "copyPattern": "多维度特写产品核心卖点,视觉放大食欲点", + "watchingPurpose": "放大重点:突出联名款的颜值和冰淇淋的诱人质感,激发用户食欲" + }, + { + "role": "closing", + "label": "团购路径引导", + "durationRatio": 0.15, + "intent": "明确告知用户购买渠道,降低行动门槛", + "copyPattern": "直接给出搜索关键词+操作指引", + "watchingPurpose": "收束记忆点:明确告知用户购买渠道,降低行动门槛" + } + ], + "notes": [ + "脚本结构由 4 个段落组成,按 hook -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 14.395, + "shotCount": 12, + "avgShotSec": 1.2, + "cutDensity": "high", + "peakAt": 0.65, + "beatHints": [ + "轻快甜品向BGM卡点每一次倾倒/放置动作", + "高潮段BGM节奏小幅上扬烘托食欲感", + "收尾引导段BGM放缓突出引导文字" + ], + "rhythmNotes": [ + "平均 1.2s/镜,整体为 快节奏。", + "高潮位置约在全片 65%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「顶部窄条放置联名款产品名称」协同", + "animation": "字幕/标题可能配合「快切为主+收尾段淡入淡出」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "顶部窄条放置联名款产品名称", + "stickerUsage": "仅少量添加卡通联名元素贴纸点缀不遮挡产品主体", + "coverStyle": "选多杯联名饮品全景图配醒目限定款文字", + "overlayStyle": "顶部窄条放置联名款产品名称 / 仅少量添加卡通联名元素贴纸点缀不遮挡产品主体", + "notes": [ + "画面包装迁移重点:顶部窄条放置联名款产品名称;仅少量添加卡通联名元素贴纸点缀不遮挡产品主体;选多杯联名饮品全景图配醒目限定款文字", + "模板画幅:full_bleed,viewport=720:960,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "快切为主+收尾段淡入淡出", + "frequency": "高频切换", + "notableTransitions": [ + "快切为主+收尾段淡入淡出" + ], + "executableTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=high 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "snap_cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_beat_pulse", + "name": "图片素材运镜:beat_pulse", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "beat_pulse", + "implementationNotes": "已映射到 MotionPreset=beat_pulse,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunctions": [ + "opening_hook", + "transition", + "cta" + ], + "requiredRenderer": "remotion", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻快甜品向BGM卡点每一次倾倒/放置动作", + "高潮段BGM节奏小幅上扬烘托食欲感", + "收尾引导段BGM放缓突出引导文字" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll", + "product_closeup" + ], + "minDurationSec": 1, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "usage_demo", + "product_closeup" + ], + "minDurationSec": 7, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "climax", + "requiredAssetTypes": [ + "product_closeup", + "b_roll" + ], + "minDurationSec": 3, + "optional": false + }, + { + "slotId": "slot_004", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 2, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 14.395s", + "ref": "seed:v0200fg10000d826j7vog65r4gii35u0.MP4" + }, + { + "type": "resolution", + "detail": "720x960 @ 30fps" + }, + { + "type": "scene_cut", + "detail": "12 个镜头 / 11 个切点(原始 11 个,已合并 <0.4s 密集检测)", + "ref": "1.00, 2.00, 2.83, 3.90, 4.90, 6.50, 7.50, 8.37, 9.37, 10.37, 11.37" + }, + { + "type": "template_profile", + "detail": "full_bleed;内部运动 low;模板事件 3 个;音频 onset 3 个", + "ref": "full_bleed, ken_burns_in, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "整个编排逻辑适配短视频短平快的种草属性,开场直接用限定联名款的高颜值陈列第一时间抓住喜爱联名饮品的年轻用户注意力,紧接着用高密度快切的分步制作演示强化产品真实用料的信任感,中段高潮部分放大冰淇淋和成品的诱人细节充分激发用户的购买欲,最后直接给出清晰的搜索团购路径,大幅降低用户的行动门槛,全程无冗余信息,观看流畅度高。", + "createdAt": "2026-06-08T10:04:35.048Z", + "updatedAt": "2026-06-08T10:11:38.300Z" + }, + { + "id": "pattern_2cc8fd82", + "scope": "global", + "sourceSampleId": "0d2df179-1c3e-4480-9a3f-7ba74a0e5a35", + "name": "v0300fg10000d1l583nog65q6h04m86g · Vlog模式", + "summary": "该样例是 24s 的 Vlog 视频,结构为 核心主题开篇 -> 旅途场景铺垫 -> 风光内容递进 -> 情绪共鸣强化 -> 开阔远景收尾,节奏为 中等节奏。", + "videoGenre": "vlog", + "tags": [ + "vlog", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "full_story", + "ctaType": "generic_next_action", + "visualBridgePolicy": { + "use": "allowed", + "reasons": [ + "未检测到人物、水印、平台账号或事实证明风险,可作为短氛围 / 尺度 / 转场桥接候选。" + ] + }, + "commercialUsefulness": "weak", + "recommendedUse": "secondary_story", + "warnings": [ + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "Vlog:核心主题开篇 -> 开阔远景收尾", + "formula": "核心主题开篇 -> 旅途场景铺垫 -> 风光内容递进 -> 情绪共鸣强化 -> 开阔远景收尾", + "source": { + "filename": "v0300fg10000d1l583nog65q6h04m86g.MP4", + "durationSec": 24.218, + "aspectRatio": "1280:720", + "shotCount": 11 + }, + "segments": [ + { + "role": "hook", + "label": "核心主题开篇", + "durationRatio": 0.15, + "intent": "第一时间抛出核心情绪主题,快速抓取目标受众注意力", + "copyPattern": "醒目大字直接点明核心主题,搭配开篇标志性风光画面", + "watchingPurpose": "开场抓停:第一时间抛出核心情绪主题,快速抓取目标受众注意力" + }, + { + "role": "setup", + "label": "旅途场景铺垫", + "durationRatio": 0.2, + "intent": "快速铺陈多元旅途片段,建立旅行记录的氛围感认知", + "copyPattern": "碎片化混剪人文、行车等不同属性的旅途短镜头", + "watchingPurpose": "建立背景:快速铺陈多元旅途片段,建立旅行记录的氛围感认知" + }, + { + "role": "develop", + "label": "风光内容递进", + "durationRatio": 0.3, + "intent": "逐步展示不同地貌的自然风光,补充拍摄相关信息丰富内容维度", + "copyPattern": "按地貌类型递进混剪风光素材,穿插标注拍摄设备、BGM名称等辅助信息", + "watchingPurpose": "推进主体:逐步展示不同地貌的自然风光,补充拍摄相关信息丰富内容维度" + }, + { + "role": "climax", + "label": "情绪共鸣强化", + "durationRatio": 0.25, + "intent": "结合BGM歌词内容放大自由主题的情绪感染力,触发观众共情", + "copyPattern": "同步BGM歌词搭配适配的高氛围感风光画面输出", + "watchingPurpose": "放大重点:结合BGM歌词内容放大自由主题的情绪感染力,触发观众共情" + }, + { + "role": "closing", + "label": "开阔远景收尾", + "durationRatio": 0.1, + "intent": "用极致开阔的自然画面留下情绪余韵,强化主题记忆点", + "copyPattern": "大尺度自然远景定格收尾,不添加多余文字留足留白", + "watchingPurpose": "收束记忆点:用极致开阔的自然画面留下情绪余韵,强化主题记忆点" + } + ], + "pacing": { + "durationSec": 24.218, + "shotCount": 11, + "avgShotSec": 2.2, + "cutDensity": "medium", + "peakAt": 0.7, + "beatHints": [ + "舒缓氛围感电子BGM", + "每2秒左右切镜贴合轻鼓点节奏", + "慢推镜头适配旋律起伏" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "顶部醒目艺术字主题标头,无冗余装饰", + "stickerUsage": "无多余装饰贴纸,仅保留必要的信息类文字标注", + "transitionStyle": "快切+淡入淡出组合,适配不同节奏的画面段落", + "coverStyle": "带醒目红色主题大字的徒步风光主视觉封面" + }, + "storySkeleton": { + "arcType": "Vlog / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "第一时间抛出核心情绪主题,快速抓取目标受众注意力", + "快速铺陈多元旅途片段,建立旅行记录的氛围感认知", + "逐步展示不同地貌的自然风光,补充拍摄相关信息丰富内容维度", + "结合BGM歌词内容放大自由主题的情绪感染力,触发观众共情", + "用极致开阔的自然画面留下情绪余韵,强化主题记忆点" + ], + "hookStyle": "醒目大字直接点明核心主题,搭配开篇标志性风光画面", + "turnOrProofStyle": "按地貌类型递进混剪风光素材,穿插标注拍摄设备、BGM名称等辅助信息", + "payoffStyle": "大尺度自然远景定格收尾,不添加多余文字留足留白", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "vlog" + ], + "assetRequirements": [ + "b_roll", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "snap_cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "high", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunction": [ + "opening_hook", + "transition", + "cta" + ], + "appliesToAssetType": [ + "text" + ], + "appliesToVisualCluster": [], + "beatPlacement": "phrase boundary or shot boundary", + "cardAnimationPreset": "soft_crossfade", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "remotion", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ], + "packagingPattern": { + "titleBarStyle": "顶部醒目艺术字主题标头,无冗余装饰", + "stickerUsage": "无多余装饰贴纸,仅保留必要的信息类文字标注", + "coverStyle": "带醒目红色主题大字的徒步风光主视觉封面", + "overlayStyle": "顶部醒目艺术字主题标头,无冗余装饰 / 无多余装饰贴纸,仅保留必要的信息类文字标注", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 24.218, + "bpm": 112, + "beatCount": 45, + "beatStability": 0.37102325190967844, + "onsetDensity": 0.4542076141712776, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 4.359, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286 + ] + }, + { + "startSec": 4.359, + "endSec": 13.32, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 6.429, + 8.571, + 10.714, + 12.857 + ] + }, + { + "startSec": 13.32, + "endSec": 17.679, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 15, + 17.143 + ] + }, + { + "startSec": 17.679, + "endSec": 24.218, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 19.286, + 21.429, + 23.571 + ] + } + ], + "tags": [ + "vlog", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_0d2df179-1c3e-4480-9a3f-7ba74a0e5a35", + "source": "global_sample", + "durationSec": 24.218, + "music": { + "hasAudio": true, + "durationSec": 24.218, + "bpm": 112, + "beatCount": 45, + "beatStability": 0.37102325190967844, + "onsetDensity": 0.4542076141712776, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 4.359, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286 + ] + }, + { + "startSec": 4.359, + "endSec": 13.32, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 6.429, + 8.571, + 10.714, + 12.857 + ] + }, + { + "startSec": 13.32, + "endSec": 17.679, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 15, + 17.143 + ] + }, + { + "startSec": 17.679, + "endSec": 24.218, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 19.286, + 21.429, + 23.571 + ] + } + ], + "tags": [ + "vlog", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 11, + "avgShotSec": 2.2, + "peakAt": 0.7, + "cutEveryBeats": 4.043, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "核心主题开篇" + }, + { + "eventType": "cut", + "timeSec": 0.1, + "relativeTime": 0.004129160128829796, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 100, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 0.6, + "relativeTime": 0.024774960772978777, + "beatIndex": 1, + "phraseIndex": 0, + "nearestBeatSec": 0.536, + "offsetMs": 64, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 1.1, + "relativeTime": 0.04542076141712776, + "beatIndex": 2, + "phraseIndex": 0, + "nearestBeatSec": 1.071, + "offsetMs": 29, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 1.6, + "relativeTime": 0.06606656206127674, + "beatIndex": 3, + "phraseIndex": 0, + "nearestBeatSec": 1.607, + "offsetMs": -7, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 3.533, + "relativeTime": 0.14588322735155668, + "beatIndex": 7, + "phraseIndex": 0, + "nearestBeatSec": 3.75, + "offsetMs": -217, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 3.633, + "relativeTime": 0.15001238748038648, + "beatIndex": 7, + "phraseIndex": 0, + "nearestBeatSec": 3.75, + "offsetMs": -117, + "segmentRole": "setup", + "strength": "medium", + "description": "旅途场景铺垫" + }, + { + "eventType": "cut", + "timeSec": 6.533, + "relativeTime": 0.2697580312164506, + "beatIndex": 12, + "phraseIndex": 1, + "nearestBeatSec": 6.429, + "offsetMs": 104, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 8.477, + "relativeTime": 0.3500289041209018, + "beatIndex": 16, + "phraseIndex": 2, + "nearestBeatSec": 8.571, + "offsetMs": -94, + "segmentRole": "develop", + "strength": "medium", + "description": "风光内容递进" + }, + { + "eventType": "cut", + "timeSec": 10.933, + "relativeTime": 0.4514410768849616, + "beatIndex": 20, + "phraseIndex": 2, + "nearestBeatSec": 10.714, + "offsetMs": 219, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 13.167, + "relativeTime": 0.5436865141630193, + "beatIndex": 25, + "phraseIndex": 3, + "nearestBeatSec": 13.393, + "offsetMs": -226, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 15.333, + "relativeTime": 0.6331241225534726, + "beatIndex": 29, + "phraseIndex": 3, + "nearestBeatSec": 15.536, + "offsetMs": -203, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 15.742, + "relativeTime": 0.6500123874803865, + "beatIndex": 29, + "phraseIndex": 3, + "nearestBeatSec": 15.536, + "offsetMs": 206, + "segmentRole": "climax", + "strength": "medium", + "description": "情绪共鸣强化" + }, + { + "eventType": "cut", + "timeSec": 19.733, + "relativeTime": 0.8148071682219836, + "beatIndex": 37, + "phraseIndex": 4, + "nearestBeatSec": 19.821, + "offsetMs": -88, + "strength": "strong", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 21.797, + "relativeTime": 0.9000330332810307, + "beatIndex": 41, + "phraseIndex": 5, + "nearestBeatSec": 21.964, + "offsetMs": -167, + "segmentRole": "closing", + "strength": "medium", + "description": "开阔远景收尾" + } + ], + "cutIntervalsSec": [ + 0.1, + 0.5, + 0.5, + 0.5, + 1.933, + 3, + 4.4, + 2.234, + 2.166, + 4.4, + 4.485 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:11 镜,平均 2.2s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_0d2df179-1c3e-4480-9a3f-7ba74a0e5a35", + "source": "global_sample", + "durationSec": 24.218, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 12 个,硬切 10 个。", + "真实音频 onset 7 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 0.5, + "relativeTime": 0.021, + "strength": "strong", + "energyDb": -5.104 + }, + { + "timeSec": 11.5, + "relativeTime": 0.475, + "strength": "medium", + "energyDb": -7.492 + }, + { + "timeSec": 12.5, + "relativeTime": 0.516, + "strength": "strong", + "energyDb": -7.311 + }, + { + "timeSec": 13.5, + "relativeTime": 0.557, + "strength": "weak", + "energyDb": -7.903 + }, + { + "timeSec": 15, + "relativeTime": 0.619, + "strength": "medium", + "energyDb": -7.477 + }, + { + "timeSec": 16, + "relativeTime": 0.661, + "strength": "weak", + "energyDb": -7.854 + }, + { + "timeSec": 19, + "relativeTime": 0.785, + "strength": "strong", + "energyDb": -6.912 + } + ], + "events": [ + { + "kind": "audio_onset", + "timeSec": 0.5, + "relativeTime": 0.021, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 0.9, + "relativeTime": 0.037, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 6.3, + "relativeTime": 0.26, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 11.5, + "relativeTime": 0.475, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 12.5, + "relativeTime": 0.516, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 13.5, + "relativeTime": 0.557, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 15, + "relativeTime": 0.619, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 15.667, + "relativeTime": 0.647, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 16, + "relativeTime": 0.661, + "strength": "weak", + "direction": "left", + "nearestOnsetSec": 16, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 16, + "relativeTime": 0.661, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 16.333, + "relativeTime": 0.674, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 16.667, + "relativeTime": 0.688, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 17, + "relativeTime": 0.702, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 17.333, + "relativeTime": 0.716, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 17.733, + "relativeTime": 0.732, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 18.567, + "relativeTime": 0.767, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 18.9, + "relativeTime": 0.78, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 19, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 19, + "relativeTime": 0.785, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 19.4, + "relativeTime": 0.801, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "reveal_pan", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "核心主题开篇 -> 旅途场景铺垫 -> 风光内容递进 -> 情绪共鸣强化 -> 开阔远景收尾", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "核心主题开篇", + "durationRatio": 0.15, + "intent": "第一时间抛出核心情绪主题,快速抓取目标受众注意力", + "copyPattern": "醒目大字直接点明核心主题,搭配开篇标志性风光画面", + "watchingPurpose": "开场抓停:第一时间抛出核心情绪主题,快速抓取目标受众注意力" + }, + { + "role": "setup", + "label": "旅途场景铺垫", + "durationRatio": 0.2, + "intent": "快速铺陈多元旅途片段,建立旅行记录的氛围感认知", + "copyPattern": "碎片化混剪人文、行车等不同属性的旅途短镜头", + "watchingPurpose": "建立背景:快速铺陈多元旅途片段,建立旅行记录的氛围感认知" + }, + { + "role": "develop", + "label": "风光内容递进", + "durationRatio": 0.3, + "intent": "逐步展示不同地貌的自然风光,补充拍摄相关信息丰富内容维度", + "copyPattern": "按地貌类型递进混剪风光素材,穿插标注拍摄设备、BGM名称等辅助信息", + "watchingPurpose": "推进主体:逐步展示不同地貌的自然风光,补充拍摄相关信息丰富内容维度" + }, + { + "role": "climax", + "label": "情绪共鸣强化", + "durationRatio": 0.25, + "intent": "结合BGM歌词内容放大自由主题的情绪感染力,触发观众共情", + "copyPattern": "同步BGM歌词搭配适配的高氛围感风光画面输出", + "watchingPurpose": "放大重点:结合BGM歌词内容放大自由主题的情绪感染力,触发观众共情" + }, + { + "role": "closing", + "label": "开阔远景收尾", + "durationRatio": 0.1, + "intent": "用极致开阔的自然画面留下情绪余韵,强化主题记忆点", + "copyPattern": "大尺度自然远景定格收尾,不添加多余文字留足留白", + "watchingPurpose": "收束记忆点:用极致开阔的自然画面留下情绪余韵,强化主题记忆点" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 24.218, + "shotCount": 11, + "avgShotSec": 2.2, + "cutDensity": "medium", + "peakAt": 0.7, + "beatHints": [ + "舒缓氛围感电子BGM", + "每2秒左右切镜贴合轻鼓点节奏", + "慢推镜头适配旋律起伏" + ], + "rhythmNotes": [ + "平均 2.2s/镜,整体为 中等节奏。", + "高潮位置约在全片 70%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「顶部醒目艺术字主题标头,无冗余装饰」协同", + "animation": "字幕/标题可能配合「快切+淡入淡出组合,适配不同节奏的画面段落」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "顶部醒目艺术字主题标头,无冗余装饰", + "stickerUsage": "无多余装饰贴纸,仅保留必要的信息类文字标注", + "coverStyle": "带醒目红色主题大字的徒步风光主视觉封面", + "overlayStyle": "顶部醒目艺术字主题标头,无冗余装饰 / 无多余装饰贴纸,仅保留必要的信息类文字标注", + "notes": [ + "画面包装迁移重点:顶部醒目艺术字主题标头,无冗余装饰;无多余装饰贴纸,仅保留必要的信息类文字标注;带醒目红色主题大字的徒步风光主视觉封面", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "快切+淡入淡出组合,适配不同节奏的画面段落", + "frequency": "中等频率切换", + "notableTransitions": [ + "快切+淡入淡出组合,适配不同节奏的画面段落" + ], + "executableTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "snap_cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunctions": [ + "opening_hook", + "transition", + "cta" + ], + "requiredRenderer": "remotion", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "舒缓氛围感电子BGM", + "每2秒左右切镜贴合轻鼓点节奏", + "慢推镜头适配旋律起伏" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 1, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 5, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "climax", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 6, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 24.218s", + "ref": "seed:v0300fg10000d1l583nog65q6h04m86g.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 30fps" + }, + { + "type": "scene_cut", + "detail": "11 个镜头 / 10 个切点(原始 24 个,已合并 <0.4s 密集检测)", + "ref": "0.10, 0.60, 1.10, 1.60, 3.53, 6.53, 10.93, 13.17, 15.33, 19.73" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 medium;模板事件 19 个;音频 onset 7 个", + "ref": "letterbox_frame, reveal_pan, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 10 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构从开篇直接点出自由主题快速抓取热爱旅行风光的受众注意力,随后用碎片化旅途片段快速铺垫氛围感,再递进展示不同地貌的风光内容逐步拉高观众情绪,高潮部分同步BGM歌词强化情绪共鸣,最后用极致开阔的自然远景收尾留足情绪余韵,全程节奏贴合舒缓BGM的节拍,适配短时长氛围感旅行vlog的观看体验,不会让观众产生视觉疲劳。", + "createdAt": "2026-06-08T10:06:06.426Z", + "updatedAt": "2026-06-08T10:11:40.640Z" + }, + { + "id": "pattern_0a14b5f9", + "scope": "global", + "sourceSampleId": "252a0f29-6aa0-4c49-befa-0a6fe9bc5125", + "name": "v0300fg10000d5hn1nfog65tb8v9tuk0 · 教程模式", + "summary": "该样例是 11s 的 教程 视频,结构为 户外实景场景展示 -> 搜索路径引导,节奏为 慢节奏。", + "videoGenre": "tutorial", + "tags": [ + "tutorial", + "low", + "hook", + "closing" + ], + "qualityTags": { + "patternDepth": "template_or_editing_only", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "not_recommended", + "recommendedUse": "template_only", + "warnings": [ + "这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。", + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": false, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "教程:户外实景场景展示 -> 搜索路径引导", + "formula": "户外实景场景展示 -> 搜索路径引导", + "source": { + "filename": "v0300fg10000d5hn1nfog65tb8v9tuk0.MP4", + "durationSec": 11.262, + "aspectRatio": "1280:720", + "shotCount": 2 + }, + "segments": [ + { + "role": "hook", + "label": "户外实景场景展示", + "durationRatio": 0.73, + "intent": "用真实户外拍摄场景精准抓取对运镜技巧感兴趣的目标用户注意力,快速建立场景共鸣", + "copyPattern": "实景场景前置代入,直接展示拍摄相关的真实户外环境", + "watchingPurpose": "开场抓停:用真实户外拍摄场景精准抓取对运镜技巧感兴趣的目标用户注意力,快速建立场景共鸣" + }, + { + "role": "closing", + "label": "搜索路径引导", + "durationRatio": 0.27, + "intent": "清晰告知用户获取完整教学内容的操作路径,引导用户完成后续搜索动作", + "copyPattern": "平台原生搜索指引,搭配二维码辅助扫码跳转", + "watchingPurpose": "收束记忆点:清晰告知用户获取完整教学内容的操作路径,引导用户完成后续搜索动作" + } + ], + "pacing": { + "durationSec": 11.262, + "shotCount": 2, + "avgShotSec": 5.63, + "cutDensity": "low", + "peakAt": 0.85, + "beatHints": [ + "轻缓户外氛围BGM铺垫", + "收尾提示音卡点引导注意力" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "左上角悬浮账号信息栏,搭配平台官方标识", + "stickerUsage": "无额外装饰贴纸,仅保留必要的功能提示文字", + "transitionStyle": "硬切直转,无多余转场特效", + "coverStyle": "户外步道博主实拍画面,叠加账号水印突出创作者身份" + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "pan_left", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "左上角悬浮账号信息栏,搭配平台官方标识", + "stickerUsage": "无额外装饰贴纸,仅保留必要的功能提示文字", + "coverStyle": "户外步道博主实拍画面,叠加账号水印突出创作者身份", + "overlayStyle": "左上角悬浮账号信息栏,搭配平台官方标识 / 无额外装饰贴纸,仅保留必要的功能提示文字", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 11.262, + "bpm": 92, + "beatCount": 17, + "beatStability": 0.6839548159844528, + "onsetDensity": 0.17758835020422659, + "energyShape": "late_peak", + "peakAt": 0.85, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.027, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 2.027, + "endSec": 7.658, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609, + 5.217 + ] + }, + { + "startSec": 7.658, + "endSec": 9.685, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 7.826 + ] + }, + { + "startSec": 9.685, + "endSec": 11.262, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 10.435 + ] + } + ], + "tags": [ + "tutorial", + "density:low", + "bpm:92", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_252a0f29-6aa0-4c49-befa-0a6fe9bc5125", + "source": "global_sample", + "durationSec": 11.262, + "music": { + "hasAudio": true, + "durationSec": 11.262, + "bpm": 92, + "beatCount": 17, + "beatStability": 0.6839548159844528, + "onsetDensity": 0.17758835020422659, + "energyShape": "late_peak", + "peakAt": 0.85, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.609, + 5.217, + 7.826, + 10.435 + ], + "phraseBoundariesSec": [ + 0, + 5.217, + 10.435 + ], + "sections": [ + { + "startSec": 0, + "endSec": 2.027, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0 + ] + }, + { + "startSec": 2.027, + "endSec": 7.658, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 2.609, + 5.217 + ] + }, + { + "startSec": 7.658, + "endSec": 9.685, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 7.826 + ] + }, + { + "startSec": 9.685, + "endSec": 11.262, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 10.435 + ] + } + ], + "tags": [ + "tutorial", + "density:low", + "bpm:92", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "low", + "shotCount": 2, + "avgShotSec": 5.63, + "peakAt": 0.85, + "cutEveryBeats": 12.624, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "户外实景场景展示" + }, + { + "eventType": "caption", + "timeSec": 8.221, + "relativeTime": 0.7299769135144735, + "beatIndex": 13, + "phraseIndex": 1, + "nearestBeatSec": 8.478, + "offsetMs": -257, + "segmentRole": "closing", + "strength": "medium", + "description": "搜索路径引导" + }, + { + "eventType": "cut", + "timeSec": 8.233, + "relativeTime": 0.7310424436156988, + "beatIndex": 13, + "phraseIndex": 1, + "nearestBeatSec": 8.478, + "offsetMs": -245, + "strength": "strong", + "description": "样例第 1 个切镜点" + } + ], + "cutIntervalsSec": [ + 8.233, + 3.029 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:2 镜,平均 5.6s/镜,慢节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_252a0f29-6aa0-4c49-befa-0a6fe9bc5125", + "source": "global_sample", + "durationSec": 11.262, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 5 个,硬切 1 个。", + "真实音频 onset 2 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 3, + "relativeTime": 0.266, + "strength": "medium", + "energyDb": -8.343 + }, + { + "timeSec": 5.5, + "relativeTime": 0.488, + "strength": "strong", + "energyDb": -7.734 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 0.733, + "relativeTime": 0.065, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 1.333, + "relativeTime": 0.118, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 3, + "relativeTime": 0.266, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 4.033, + "relativeTime": 0.358, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.633, + "relativeTime": 0.411, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.4, + "relativeTime": 0.479, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 5.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 5.5, + "relativeTime": 0.488, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "reveal_pan", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "户外实景场景展示 -> 搜索路径引导", + "segmentCount": 2, + "segments": [ + { + "role": "hook", + "label": "户外实景场景展示", + "durationRatio": 0.73, + "intent": "用真实户外拍摄场景精准抓取对运镜技巧感兴趣的目标用户注意力,快速建立场景共鸣", + "copyPattern": "实景场景前置代入,直接展示拍摄相关的真实户外环境", + "watchingPurpose": "开场抓停:用真实户外拍摄场景精准抓取对运镜技巧感兴趣的目标用户注意力,快速建立场景共鸣" + }, + { + "role": "closing", + "label": "搜索路径引导", + "durationRatio": 0.27, + "intent": "清晰告知用户获取完整教学内容的操作路径,引导用户完成后续搜索动作", + "copyPattern": "平台原生搜索指引,搭配二维码辅助扫码跳转", + "watchingPurpose": "收束记忆点:清晰告知用户获取完整教学内容的操作路径,引导用户完成后续搜索动作" + } + ], + "notes": [ + "脚本结构由 2 个段落组成,按 hook -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 11.262, + "shotCount": 2, + "avgShotSec": 5.63, + "cutDensity": "low", + "peakAt": 0.85, + "beatHints": [ + "轻缓户外氛围BGM铺垫", + "收尾提示音卡点引导注意力" + ], + "rhythmNotes": [ + "平均 5.6s/镜,整体为 慢节奏。", + "高潮位置约在全片 85%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「左上角悬浮账号信息栏,搭配平台官方标识」协同", + "animation": "字幕/标题可能配合「硬切直转,无多余转场特效」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "左上角悬浮账号信息栏,搭配平台官方标识", + "stickerUsage": "无额外装饰贴纸,仅保留必要的功能提示文字", + "coverStyle": "户外步道博主实拍画面,叠加账号水印突出创作者身份", + "overlayStyle": "左上角悬浮账号信息栏,搭配平台官方标识 / 无额外装饰贴纸,仅保留必要的功能提示文字", + "notes": [ + "画面包装迁移重点:左上角悬浮账号信息栏,搭配平台官方标识;无额外装饰贴纸,仅保留必要的功能提示文字;户外步道博主实拍画面,叠加账号水印突出创作者身份", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "硬切直转,无多余转场特效", + "frequency": "低频切换", + "notableTransitions": [ + "硬切直转,无多余转场特效" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=low 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_pan_left", + "name": "图片素材运镜:pan_left", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "pan_left", + "implementationNotes": "已映射到 MotionPreset=pan_left,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻缓户外氛围BGM铺垫", + "收尾提示音卡点引导注意力" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "talking_head", + "b_roll" + ], + "minDurationSec": 6, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 11.262s", + "ref": "seed:v0300fg10000d5hn1nfog65tb8v9tuk0.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 30fps" + }, + { + "type": "scene_cut", + "detail": "2 个镜头 / 1 个切点(原始 1 个,已合并 <0.4s 密集检测)", + "ref": "8.23" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 medium;模板事件 7 个;音频 onset 2 个", + "ref": "letterbox_frame, reveal_pan, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 2 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该短教程引流视频先通过高相关性的户外实拍场景快速筛选目标受众,提前建立内容信任度,避免无关用户快速划走;后续直接给出清晰无歧义的搜索操作指引,大幅降低用户获取完整教程的行动成本,整体节奏平缓镜头切换极少,不会分散用户注意力,最终实现高转化的引流效果。", + "createdAt": "2026-06-08T10:11:43.698Z", + "updatedAt": "2026-06-08T10:11:44.750Z" + }, + { + "id": "pattern_f56e4f19", + "scope": "global", + "sourceSampleId": "30547758-8d0a-4928-83b5-fa8faeb810a8", + "name": "v0300fg10000d7bobsvog65hbmps2br0 · 教程模式", + "summary": "该样例是 22s 的 教程 视频,结构为 互动式动作开场 -> 核心特效抛出 -> 多场景适配演示 -> 完整转场链路呈现 -> 教程获取引导,节奏为 中等节奏。", + "videoGenre": "tutorial", + "tags": [ + "tutorial", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "story_candidate", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "教程:互动式动作开场 -> 教程获取引导", + "formula": "互动式动作开场 -> 核心特效抛出 -> 多场景适配演示 -> 完整转场链路呈现 -> 教程获取引导", + "source": { + "filename": "v0300fg10000d7bobsvog65hbmps2br0.MP4", + "durationSec": 22.059, + "aspectRatio": "720:1280", + "shotCount": 5 + }, + "segments": [ + { + "role": "hook", + "label": "互动式动作开场", + "durationRatio": 0.15, + "intent": "快速建立第一人称代入感,引发观众对后续动作的好奇", + "copyPattern": "动作引导悬念", + "watchingPurpose": "开场抓停:快速建立第一人称代入感,引发观众对后续动作的好奇" + }, + { + "role": "setup", + "label": "核心特效抛出", + "durationRatio": 0.1, + "intent": "直接展示本次教程的核心创意转场效果,抓住剪辑爱好者注意力", + "copyPattern": "视觉冲击式特效展示", + "watchingPurpose": "建立背景:直接展示本次教程的核心创意转场效果,抓住剪辑爱好者注意力" + }, + { + "role": "develop", + "label": "多场景适配演示", + "durationRatio": 0.45, + "intent": "展示该转场效果可适配不同类型的素材,体现实用性", + "copyPattern": "跨场景效果验证", + "watchingPurpose": "推进主体:展示该转场效果可适配不同类型的素材,体现实用性" + }, + { + "role": "climax", + "label": "完整转场链路呈现", + "durationRatio": 0.2, + "intent": "完整呈现从触发动作到转场落地的全流程效果,强化观众对转场逻辑的认知", + "copyPattern": "全流程效果串联", + "watchingPurpose": "放大重点:完整呈现从触发动作到转场落地的全流程效果,强化观众对转场逻辑的认知" + }, + { + "role": "closing", + "label": "教程获取引导", + "durationRatio": 0.1, + "intent": "给出明确的教程获取路径,完成引流转化", + "copyPattern": "清晰行动指令输出", + "watchingPurpose": "收束记忆点:给出明确的教程获取路径,完成引流转化" + } + ], + "pacing": { + "durationSec": 22.059, + "shotCount": 5, + "avgShotSec": 4.41, + "cutDensity": "medium", + "peakAt": 0.6, + "beatHints": [ + "鼓点卡点匹配碎裂转场触发瞬间", + "舒缓背景音适配多场景展示段落" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "顶部悬浮账号标识无冗余标题", + "stickerUsage": "全程无额外装饰贴纸", + "transitionStyle": "自定义碎裂特效转场作为核心串联元素", + "coverStyle": "第一人称伸手指向画面的强互动感封面" + }, + "storySkeleton": { + "arcType": "教程 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "快速建立第一人称代入感,引发观众对后续动作的好奇", + "直接展示本次教程的核心创意转场效果,抓住剪辑爱好者注意力", + "展示该转场效果可适配不同类型的素材,体现实用性", + "完整呈现从触发动作到转场落地的全流程效果,强化观众对转场逻辑的认知", + "给出明确的教程获取路径,完成引流转化" + ], + "hookStyle": "动作引导悬念", + "turnOrProofStyle": "跨场景效果验证", + "payoffStyle": "清晰行动指令输出", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "tutorial" + ], + "assetRequirements": [ + "talking_head", + "b_roll", + "comparison", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "顶部悬浮账号标识无冗余标题", + "stickerUsage": "全程无额外装饰贴纸", + "coverStyle": "第一人称伸手指向画面的强互动感封面", + "overlayStyle": "顶部悬浮账号标识无冗余标题 / 全程无额外装饰贴纸", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 22.059, + "bpm": 112, + "beatCount": 41, + "beatStability": 0.5099374999999999, + "onsetDensity": 0.22666485334783987, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 3.971, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143 + ] + }, + { + "startSec": 3.971, + "endSec": 12.132, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 4.286, + 6.429, + 8.571, + 10.714 + ] + }, + { + "startSec": 12.132, + "endSec": 16.103, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 12.857, + 15 + ] + }, + { + "startSec": 16.103, + "endSec": 22.059, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 17.143, + 19.286, + 21.429 + ] + } + ], + "tags": [ + "tutorial", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_30547758-8d0a-4928-83b5-fa8faeb810a8", + "source": "global_sample", + "durationSec": 22.059, + "music": { + "hasAudio": true, + "durationSec": 22.059, + "bpm": 112, + "beatCount": 41, + "beatStability": 0.5099374999999999, + "onsetDensity": 0.22666485334783987, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 3.971, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143 + ] + }, + { + "startSec": 3.971, + "endSec": 12.132, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 4.286, + 6.429, + 8.571, + 10.714 + ] + }, + { + "startSec": 12.132, + "endSec": 16.103, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 12.857, + 15 + ] + }, + { + "startSec": 16.103, + "endSec": 22.059, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 17.143, + 19.286, + 21.429 + ] + } + ], + "tags": [ + "tutorial", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 5, + "avgShotSec": 4.41, + "peakAt": 0.6, + "cutEveryBeats": 5.973, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "互动式动作开场" + }, + { + "eventType": "cut", + "timeSec": 3.2, + "relativeTime": 0.14506550614261754, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 3.214, + "offsetMs": -14, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 3.309, + "relativeTime": 0.15000679994560043, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 3.214, + "offsetMs": 95, + "segmentRole": "setup", + "strength": "medium", + "description": "核心特效抛出" + }, + { + "eventType": "caption", + "timeSec": 5.515, + "relativeTime": 0.2500113332426674, + "beatIndex": 10, + "phraseIndex": 1, + "nearestBeatSec": 5.357, + "offsetMs": 158, + "segmentRole": "develop", + "strength": "medium", + "description": "多场景适配演示" + }, + { + "eventType": "cut", + "timeSec": 5.7, + "relativeTime": 0.25839793281653745, + "beatIndex": 11, + "phraseIndex": 1, + "nearestBeatSec": 5.893, + "offsetMs": -193, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 13.483, + "relativeTime": 0.611224443537785, + "beatIndex": 25, + "phraseIndex": 3, + "nearestBeatSec": 13.393, + "offsetMs": 90, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 15.442, + "relativeTime": 0.7000317330794686, + "beatIndex": 29, + "phraseIndex": 3, + "nearestBeatSec": 15.536, + "offsetMs": -94, + "segmentRole": "climax", + "strength": "medium", + "description": "完整转场链路呈现" + }, + { + "eventType": "cut", + "timeSec": 19.05, + "relativeTime": 0.86359309125527, + "beatIndex": 36, + "phraseIndex": 4, + "nearestBeatSec": 19.286, + "offsetMs": -236, + "strength": "strong", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 19.854, + "relativeTime": 0.9000407996736025, + "beatIndex": 37, + "phraseIndex": 4, + "nearestBeatSec": 19.821, + "offsetMs": 33, + "segmentRole": "closing", + "strength": "medium", + "description": "教程获取引导" + } + ], + "cutIntervalsSec": [ + 3.2, + 2.5, + 7.783, + 5.567, + 3.009 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:5 镜,平均 4.4s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_30547758-8d0a-4928-83b5-fa8faeb810a8", + "source": "global_sample", + "durationSec": 22.059, + "sourceAspect": "720:1280", + "targetCanvasAspect": "9:16", + "layoutPreset": "full_bleed", + "frameStyle": { + "backgroundColor": "#050505", + "matte": false, + "roundedMask": false, + "labelStyle": "none", + "viewport": { + "aspectRatio": "720:1280", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "high", + "hasMaskReveals": true, + "hasViewportSlides": false, + "preferredMotionPreset": "beat_pulse", + "preferredTransitionPreset": "snap_cut", + "notes": [ + "低阈值画面变化 25 个,硬切 4 个。", + "真实音频 onset 6 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 3.5, + "relativeTime": 0.159, + "strength": "strong", + "energyDb": -12.436 + }, + { + "timeSec": 5.5, + "relativeTime": 0.249, + "strength": "strong", + "energyDb": -12.155 + }, + { + "timeSec": 7, + "relativeTime": 0.317, + "strength": "weak", + "energyDb": -14.012 + }, + { + "timeSec": 11.5, + "relativeTime": 0.521, + "strength": "strong", + "energyDb": -11.925 + }, + { + "timeSec": 13.5, + "relativeTime": 0.612, + "strength": "strong", + "energyDb": -12.876 + }, + { + "timeSec": 15, + "relativeTime": 0.68, + "strength": "strong", + "energyDb": -12.523 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 2.55, + "relativeTime": 0.116, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 3.5, + "relativeTime": 0.159, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 3.517, + "relativeTime": 0.159, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 3.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 3.85, + "relativeTime": 0.175, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.183, + "relativeTime": 0.19, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.533, + "relativeTime": 0.206, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 4.933, + "relativeTime": 0.224, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 5.35, + "relativeTime": 0.243, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 5.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 5.5, + "relativeTime": 0.249, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 6.117, + "relativeTime": 0.277, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 6.867, + "relativeTime": 0.311, + "strength": "medium", + "direction": "unknown", + "nearestOnsetSec": 7, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 7, + "relativeTime": 0.317, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 8.633, + "relativeTime": 0.391, + "strength": "medium", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 9.467, + "relativeTime": 0.429, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 10.7, + "relativeTime": 0.485, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 11.167, + "relativeTime": 0.506, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 11.5, + "relativeTime": 0.521, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 11.567, + "relativeTime": 0.524, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 11.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 12.65, + "relativeTime": 0.573, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 12.983, + "relativeTime": 0.589, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 13.5, + "relativeTime": 0.612, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 15, + "relativeTime": 0.68, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。", + "renderHints": [ + "full_bleed", + "beat_pulse", + "snap_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "互动式动作开场 -> 核心特效抛出 -> 多场景适配演示 -> 完整转场链路呈现 -> 教程获取引导", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "互动式动作开场", + "durationRatio": 0.15, + "intent": "快速建立第一人称代入感,引发观众对后续动作的好奇", + "copyPattern": "动作引导悬念", + "watchingPurpose": "开场抓停:快速建立第一人称代入感,引发观众对后续动作的好奇" + }, + { + "role": "setup", + "label": "核心特效抛出", + "durationRatio": 0.1, + "intent": "直接展示本次教程的核心创意转场效果,抓住剪辑爱好者注意力", + "copyPattern": "视觉冲击式特效展示", + "watchingPurpose": "建立背景:直接展示本次教程的核心创意转场效果,抓住剪辑爱好者注意力" + }, + { + "role": "develop", + "label": "多场景适配演示", + "durationRatio": 0.45, + "intent": "展示该转场效果可适配不同类型的素材,体现实用性", + "copyPattern": "跨场景效果验证", + "watchingPurpose": "推进主体:展示该转场效果可适配不同类型的素材,体现实用性" + }, + { + "role": "climax", + "label": "完整转场链路呈现", + "durationRatio": 0.2, + "intent": "完整呈现从触发动作到转场落地的全流程效果,强化观众对转场逻辑的认知", + "copyPattern": "全流程效果串联", + "watchingPurpose": "放大重点:完整呈现从触发动作到转场落地的全流程效果,强化观众对转场逻辑的认知" + }, + { + "role": "closing", + "label": "教程获取引导", + "durationRatio": 0.1, + "intent": "给出明确的教程获取路径,完成引流转化", + "copyPattern": "清晰行动指令输出", + "watchingPurpose": "收束记忆点:给出明确的教程获取路径,完成引流转化" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 22.059, + "shotCount": 5, + "avgShotSec": 4.41, + "cutDensity": "medium", + "peakAt": 0.6, + "beatHints": [ + "鼓点卡点匹配碎裂转场触发瞬间", + "舒缓背景音适配多场景展示段落" + ], + "rhythmNotes": [ + "平均 4.4s/镜,整体为 中等节奏。", + "高潮位置约在全片 60%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「顶部悬浮账号标识无冗余标题」协同", + "animation": "字幕/标题可能配合「自定义碎裂特效转场作为核心串联元素」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "顶部悬浮账号标识无冗余标题", + "stickerUsage": "全程无额外装饰贴纸", + "coverStyle": "第一人称伸手指向画面的强互动感封面", + "overlayStyle": "顶部悬浮账号标识无冗余标题 / 全程无额外装饰贴纸", + "notes": [ + "画面包装迁移重点:顶部悬浮账号标识无冗余标题;全程无额外装饰贴纸;第一人称伸手指向画面的强互动感封面", + "模板画幅:full_bleed,viewport=720:1280,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "自定义碎裂特效转场作为核心串联元素", + "frequency": "中等频率切换", + "notableTransitions": [ + "自定义碎裂特效转场作为核心串联元素" + ], + "executableTechniques": [ + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "鼓点卡点匹配碎裂转场触发瞬间", + "舒缓背景音适配多场景展示段落" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "talking_head" + ], + "minDurationSec": 3, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll", + "comparison" + ], + "minDurationSec": 8, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 22.059s", + "ref": "seed:v0300fg10000d7bobsvog65hbmps2br0.MP4" + }, + { + "type": "resolution", + "detail": "720x1280 @ 56.045fps" + }, + { + "type": "scene_cut", + "detail": "5 个镜头 / 4 个切点(原始 4 个,已合并 <0.4s 密集检测)", + "ref": "3.20, 5.70, 13.48, 19.05" + }, + { + "type": "template_profile", + "detail": "full_bleed;内部运动 high;模板事件 22 个;音频 onset 6 个", + "ref": "full_bleed, beat_pulse, snap_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 5 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构从第一人称互动动作开场快速抓住观众注意力,紧接着抛出极具冲击力的碎裂转场特效精准命中剪辑爱好者的兴趣点,随后通过不同类型素材的适配演示直观体现转场的实用价值,最后给出清晰的教程获取路径,全程节奏卡点匹配特效节点,避免教程类视频的枯燥感,大幅降低中途流失率。", + "createdAt": "2026-06-08T10:07:34.151Z", + "updatedAt": "2026-06-08T10:11:47.461Z" + }, + { + "id": "pattern_a0f53441", + "scope": "global", + "sourceSampleId": "e8ea3bd1-7981-40a6-aca6-678304133abc", + "name": "v0300fg10000d8clavfog65lllqg9efg · 教程模式", + "summary": "该样例是 216s 的 教程 视频,结构为 动态快切视觉钩子开场 -> 受众定位与教学主题引入 -> 基础技巧拆解+配套素材资源展示 -> 高阶快切组合技巧输出 -> 实操场景收尾闭环,节奏为 中等节奏。", + "videoGenre": "tutorial", + "tags": [ + "tutorial", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "story_candidate", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "教程:动态快切视觉钩子开场 -> 实操场景收尾闭环", + "formula": "动态快切视觉钩子开场 -> 受众定位与教学主题引入 -> 基础技巧拆解+配套素材资源展示 -> 高阶快切组合技巧输出 -> 实操场景收尾闭环", + "source": { + "filename": "v0300fg10000d8clavfog65lllqg9efg.MP4", + "durationSec": 216.432, + "aspectRatio": "1440:720", + "shotCount": 58 + }, + "segments": [ + { + "role": "hook", + "label": "动态快切视觉钩子开场", + "durationRatio": 0.1, + "intent": "第一时间抓住剪辑学习者的注意力,建立内容的专业动感调性", + "copyPattern": "高动态拖影快切画面冲击开场,无冗余铺垫直接展示剪辑效果", + "watchingPurpose": "开场抓停:第一时间抓住剪辑学习者的注意力,建立内容的专业动感调性" + }, + { + "role": "setup", + "label": "受众定位与教学主题引入", + "durationRatio": 0.15, + "intent": "精准筛选目标受众,抛出本次教学的核心剪辑技巧主题", + "copyPattern": "直接点明目标受众的提升需求,快速引出快切剪辑的核心教学方向", + "watchingPurpose": "建立背景:精准筛选目标受众,抛出本次教学的核心剪辑技巧主题" + }, + { + "role": "develop", + "label": "基础技巧拆解+配套素材资源展示", + "durationRatio": 0.4, + "intent": "先拆解基础剪辑逻辑,再配套推荐可直接复用的实用素材资源降低学习门槛", + "copyPattern": "基础技巧点拆解+素材网站分类逐一展示,每类资源对应明确使用场景", + "watchingPurpose": "推进主体:先拆解基础剪辑逻辑,再配套推荐可直接复用的实用素材资源降低学习门槛" + }, + { + "role": "climax", + "label": "高阶快切组合技巧输出", + "durationRatio": 0.25, + "intent": "输出核心高阶剪辑方法论,强化内容的干货价值,让观众获得可直接落地的进阶技巧", + "copyPattern": "分点拆解快切动势匹配规则+收尾升格慢动作的搭配逻辑,用示例画面佐证效果", + "watchingPurpose": "放大重点:输出核心高阶剪辑方法论,强化内容的干货价值,让观众获得可直接落地的进阶技巧" + }, + { + "role": "closing", + "label": "实操场景收尾闭环", + "durationRatio": 0.1, + "intent": "落地到剪辑软件实操场景,强化观众的学习感知,完成内容收尾", + "copyPattern": "展示剪辑软件操作界面,关联前面讲解的所有技巧的实操场景", + "watchingPurpose": "收束记忆点:落地到剪辑软件实操场景,强化观众的学习感知,完成内容收尾" + } + ], + "pacing": { + "durationSec": 216.432, + "shotCount": 58, + "avgShotSec": 3.73, + "cutDensity": "medium", + "peakAt": 0.7, + "beatHints": [ + "轻节奏电子BGM卡点适配快切画面", + "技巧讲解段落鼓点加重突出重点", + "收尾段落BGM舒缓回落引导注意力集中到实操界面" + ] + }, + "packaging": { + "subtitleDensity": "medium", + "titleBarStyle": "顶部半透明黑底白字短标题栏,仅展示当期教学核心关键词", + "stickerUsage": "低频次使用,仅在素材分类节点添加高亮提示贴纸", + "transitionStyle": "以硬切快切为主,少量动态模糊转场匹配剪辑教学主题", + "coverStyle": "带核心技巧关键词+讲师人像的信息类封面,突出剪辑提升收益点" + }, + "storySkeleton": { + "arcType": "教程 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "第一时间抓住剪辑学习者的注意力,建立内容的专业动感调性", + "精准筛选目标受众,抛出本次教学的核心剪辑技巧主题", + "先拆解基础剪辑逻辑,再配套推荐可直接复用的实用素材资源降低学习门槛", + "输出核心高阶剪辑方法论,强化内容的干货价值,让观众获得可直接落地的进阶技巧", + "落地到剪辑软件实操场景,强化观众的学习感知,完成内容收尾" + ], + "hookStyle": "高动态拖影快切画面冲击开场,无冗余铺垫直接展示剪辑效果", + "turnOrProofStyle": "基础技巧点拆解+素材网站分类逐一展示,每类资源对应明确使用场景", + "payoffStyle": "展示剪辑软件操作界面,关联前面讲解的所有技巧的实操场景", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "tutorial" + ], + "assetRequirements": [ + "b_roll", + "talking_head", + "usage_demo", + "text_card", + "comparison", + "product_closeup" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "snap_cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "high", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "顶部半透明黑底白字短标题栏,仅展示当期教学核心关键词", + "stickerUsage": "低频次使用,仅在素材分类节点添加高亮提示贴纸", + "coverStyle": "带核心技巧关键词+讲师人像的信息类封面,突出剪辑提升收益点", + "overlayStyle": "顶部半透明黑底白字短标题栏,仅展示当期教学核心关键词 / 低频次使用,仅在素材分类节点添加高亮提示贴纸", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 216.432, + "bpm": 112, + "beatCount": 404, + "beatStability": 0, + "onsetDensity": 0.26798255341169513, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571, + 100.714, + 102.857, + 105, + 107.143, + 109.286, + 111.429, + 113.571, + 115.714, + 117.857, + 120, + 122.143, + 124.286, + 126.429, + 128.571, + 130.714, + 132.857, + 135, + 137.143, + 139.286, + 141.429, + 143.571, + 145.714, + 147.857, + 150, + 152.143, + 154.286, + 156.429, + 158.571, + 160.714, + 162.857, + 165, + 167.143, + 169.286, + 171.429, + 173.571, + 175.714, + 177.857, + 180, + 182.143, + 184.286, + 186.429, + 188.571, + 190.714, + 192.857, + 195, + 197.143, + 199.286, + 201.429, + 203.571, + 205.714, + 207.857, + 210, + 212.143, + 214.286 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714, + 30, + 34.286, + 38.571, + 42.857, + 47.143, + 51.429, + 55.714, + 60, + 64.286, + 68.571, + 72.857, + 77.143, + 81.429, + 85.714, + 90, + 94.286, + 98.571, + 102.857, + 107.143, + 111.429, + 115.714, + 120, + 124.286, + 128.571, + 132.857, + 137.143, + 141.429, + 145.714, + 150, + 154.286, + 158.571, + 162.857, + 167.143, + 171.429, + 175.714, + 180, + 184.286, + 188.571, + 192.857, + 197.143, + 201.429, + 205.714, + 210, + 214.286 + ], + "sections": [ + { + "startSec": 0, + "endSec": 38.958, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571 + ] + }, + { + "startSec": 38.958, + "endSec": 119.038, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571, + 100.714, + 102.857, + 105, + 107.143, + 109.286, + 111.429, + 113.571, + 115.714, + 117.857 + ] + }, + { + "startSec": 119.038, + "endSec": 157.996, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 120, + 122.143, + 124.286, + 126.429, + 128.571, + 130.714, + 132.857, + 135, + 137.143, + 139.286, + 141.429, + 143.571, + 145.714, + 147.857, + 150, + 152.143, + 154.286, + 156.429 + ] + }, + { + "startSec": 157.996, + "endSec": 216.432, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 158.571, + 160.714, + 162.857, + 165, + 167.143, + 169.286, + 171.429, + 173.571, + 175.714, + 177.857, + 180, + 182.143, + 184.286, + 186.429, + 188.571, + 190.714, + 192.857, + 195, + 197.143, + 199.286, + 201.429, + 203.571, + 205.714, + 207.857, + 210, + 212.143, + 214.286 + ] + } + ], + "tags": [ + "tutorial", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_e8ea3bd1-7981-40a6-aca6-678304133abc", + "source": "global_sample", + "durationSec": 216.432, + "music": { + "hasAudio": true, + "durationSec": 216.432, + "bpm": 112, + "beatCount": 404, + "beatStability": 0, + "onsetDensity": 0.26798255341169513, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571, + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571, + 100.714, + 102.857, + 105, + 107.143, + 109.286, + 111.429, + 113.571, + 115.714, + 117.857, + 120, + 122.143, + 124.286, + 126.429, + 128.571, + 130.714, + 132.857, + 135, + 137.143, + 139.286, + 141.429, + 143.571, + 145.714, + 147.857, + 150, + 152.143, + 154.286, + 156.429, + 158.571, + 160.714, + 162.857, + 165, + 167.143, + 169.286, + 171.429, + 173.571, + 175.714, + 177.857, + 180, + 182.143, + 184.286, + 186.429, + 188.571, + 190.714, + 192.857, + 195, + 197.143, + 199.286, + 201.429, + 203.571, + 205.714, + 207.857, + 210, + 212.143, + 214.286 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714, + 30, + 34.286, + 38.571, + 42.857, + 47.143, + 51.429, + 55.714, + 60, + 64.286, + 68.571, + 72.857, + 77.143, + 81.429, + 85.714, + 90, + 94.286, + 98.571, + 102.857, + 107.143, + 111.429, + 115.714, + 120, + 124.286, + 128.571, + 132.857, + 137.143, + 141.429, + 145.714, + 150, + 154.286, + 158.571, + 162.857, + 167.143, + 171.429, + 175.714, + 180, + 184.286, + 188.571, + 192.857, + 197.143, + 201.429, + 205.714, + 210, + 214.286 + ], + "sections": [ + { + "startSec": 0, + "endSec": 38.958, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857, + 30, + 32.143, + 34.286, + 36.429, + 38.571 + ] + }, + { + "startSec": 38.958, + "endSec": 119.038, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 40.714, + 42.857, + 45, + 47.143, + 49.286, + 51.429, + 53.571, + 55.714, + 57.857, + 60, + 62.143, + 64.286, + 66.429, + 68.571, + 70.714, + 72.857, + 75, + 77.143, + 79.286, + 81.429, + 83.571, + 85.714, + 87.857, + 90, + 92.143, + 94.286, + 96.429, + 98.571, + 100.714, + 102.857, + 105, + 107.143, + 109.286, + 111.429, + 113.571, + 115.714, + 117.857 + ] + }, + { + "startSec": 119.038, + "endSec": 157.996, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 120, + 122.143, + 124.286, + 126.429, + 128.571, + 130.714, + 132.857, + 135, + 137.143, + 139.286, + 141.429, + 143.571, + 145.714, + 147.857, + 150, + 152.143, + 154.286, + 156.429 + ] + }, + { + "startSec": 157.996, + "endSec": 216.432, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 158.571, + 160.714, + 162.857, + 165, + 167.143, + 169.286, + 171.429, + 173.571, + 175.714, + 177.857, + 180, + 182.143, + 184.286, + 186.429, + 188.571, + 190.714, + 192.857, + 195, + 197.143, + 199.286, + 201.429, + 203.571, + 205.714, + 207.857, + 210, + 212.143, + 214.286 + ] + } + ], + "tags": [ + "tutorial", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 58, + "avgShotSec": 3.73, + "peakAt": 0.7, + "cutEveryBeats": 1.617, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "动态快切视觉钩子开场" + }, + { + "eventType": "cut", + "timeSec": 0.05, + "relativeTime": 0.00023101944259628894, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 50, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 0.567, + "relativeTime": 0.002619760479041916, + "beatIndex": 1, + "phraseIndex": 0, + "nearestBeatSec": 0.536, + "offsetMs": 31, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 1.05, + "relativeTime": 0.004851408294522067, + "beatIndex": 2, + "phraseIndex": 0, + "nearestBeatSec": 1.071, + "offsetMs": -21, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.283, + "relativeTime": 0.052131847416278555, + "beatIndex": 21, + "phraseIndex": 2, + "nearestBeatSec": 11.25, + "offsetMs": 33, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.883, + "relativeTime": 0.05490408072743402, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 11.786, + "offsetMs": 97, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 18.8, + "relativeTime": 0.08686331041620464, + "beatIndex": 35, + "phraseIndex": 4, + "nearestBeatSec": 18.75, + "offsetMs": 50, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 20.483, + "relativeTime": 0.09463942485399572, + "beatIndex": 38, + "phraseIndex": 4, + "nearestBeatSec": 20.357, + "offsetMs": 126, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 21.483, + "relativeTime": 0.0992598137059215, + "beatIndex": 40, + "phraseIndex": 5, + "nearestBeatSec": 21.429, + "offsetMs": 54, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 21.643, + "relativeTime": 0.09999907592222962, + "beatIndex": 40, + "phraseIndex": 5, + "nearestBeatSec": 21.429, + "offsetMs": 214, + "segmentRole": "setup", + "strength": "medium", + "description": "受众定位与教学主题引入" + }, + { + "eventType": "cut", + "timeSec": 22.283, + "relativeTime": 0.10295612478746212, + "beatIndex": 42, + "phraseIndex": 5, + "nearestBeatSec": 22.5, + "offsetMs": -217, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 22.75, + "relativeTime": 0.10511384638131145, + "beatIndex": 42, + "phraseIndex": 5, + "nearestBeatSec": 22.5, + "offsetMs": 250, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 23.183, + "relativeTime": 0.10711447475419532, + "beatIndex": 43, + "phraseIndex": 5, + "nearestBeatSec": 23.036, + "offsetMs": 147, + "strength": "medium", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 37.533, + "relativeTime": 0.17341705477933025, + "beatIndex": 70, + "phraseIndex": 8, + "nearestBeatSec": 37.5, + "offsetMs": 33, + "strength": "medium", + "description": "样例第 12 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 38.083, + "relativeTime": 0.1759582686478894, + "beatIndex": 71, + "phraseIndex": 8, + "nearestBeatSec": 38.036, + "offsetMs": 47, + "strength": "medium", + "description": "样例第 13 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 45.017, + "relativeTime": 0.20799604494714277, + "beatIndex": 84, + "phraseIndex": 10, + "nearestBeatSec": 45, + "offsetMs": 17, + "strength": "medium", + "description": "样例第 14 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 54.108, + "relativeTime": 0.25, + "beatIndex": 101, + "phraseIndex": 12, + "nearestBeatSec": 54.107, + "offsetMs": 1, + "segmentRole": "develop", + "strength": "medium", + "description": "基础技巧拆解+配套素材资源展示" + }, + { + "eventType": "cut", + "timeSec": 56.833, + "relativeTime": 0.26259055962149774, + "beatIndex": 106, + "phraseIndex": 13, + "nearestBeatSec": 56.786, + "offsetMs": 47, + "strength": "medium", + "description": "样例第 15 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 59.7, + "relativeTime": 0.275837214459969, + "beatIndex": 111, + "phraseIndex": 13, + "nearestBeatSec": 59.464, + "offsetMs": 236, + "strength": "medium", + "description": "样例第 16 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 61.9, + "relativeTime": 0.2860020699342057, + "beatIndex": 116, + "phraseIndex": 14, + "nearestBeatSec": 62.143, + "offsetMs": -243, + "strength": "medium", + "description": "样例第 17 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 62.467, + "relativeTime": 0.28862183041324757, + "beatIndex": 117, + "phraseIndex": 14, + "nearestBeatSec": 62.679, + "offsetMs": -212, + "strength": "medium", + "description": "样例第 18 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 69.6, + "relativeTime": 0.32157906409403414, + "beatIndex": 130, + "phraseIndex": 16, + "nearestBeatSec": 69.643, + "offsetMs": -43, + "strength": "medium", + "description": "样例第 19 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 70.1, + "relativeTime": 0.32388925851999706, + "beatIndex": 131, + "phraseIndex": 16, + "nearestBeatSec": 70.179, + "offsetMs": -79, + "strength": "medium", + "description": "样例第 20 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 70.75, + "relativeTime": 0.3268925112737488, + "beatIndex": 132, + "phraseIndex": 16, + "nearestBeatSec": 70.714, + "offsetMs": 36, + "strength": "medium", + "description": "样例第 21 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 71.2, + "relativeTime": 0.3289716862571154, + "beatIndex": 133, + "phraseIndex": 16, + "nearestBeatSec": 71.25, + "offsetMs": -50, + "strength": "medium", + "description": "样例第 22 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 77.633, + "relativeTime": 0.3586946477415539, + "beatIndex": 145, + "phraseIndex": 18, + "nearestBeatSec": 77.679, + "offsetMs": -46, + "strength": "medium", + "description": "样例第 23 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 79.067, + "relativeTime": 0.3653202853552155, + "beatIndex": 148, + "phraseIndex": 18, + "nearestBeatSec": 79.286, + "offsetMs": -219, + "strength": "medium", + "description": "样例第 24 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 79.517, + "relativeTime": 0.3673994603385821, + "beatIndex": 148, + "phraseIndex": 18, + "nearestBeatSec": 79.286, + "offsetMs": 231, + "strength": "medium", + "description": "样例第 25 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 80, + "relativeTime": 0.36963110815406225, + "beatIndex": 149, + "phraseIndex": 18, + "nearestBeatSec": 79.821, + "offsetMs": 179, + "strength": "medium", + "description": "样例第 26 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 80.533, + "relativeTime": 0.3720937754121387, + "beatIndex": 150, + "phraseIndex": 18, + "nearestBeatSec": 80.357, + "offsetMs": 176, + "strength": "medium", + "description": "样例第 27 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 81.533, + "relativeTime": 0.3767141642640645, + "beatIndex": 152, + "phraseIndex": 19, + "nearestBeatSec": 81.429, + "offsetMs": 104, + "strength": "medium", + "description": "样例第 28 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 84.667, + "relativeTime": 0.3911944629259999, + "beatIndex": 158, + "phraseIndex": 19, + "nearestBeatSec": 84.643, + "offsetMs": 24, + "strength": "medium", + "description": "样例第 29 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 85.533, + "relativeTime": 0.3951957196717676, + "beatIndex": 160, + "phraseIndex": 20, + "nearestBeatSec": 85.714, + "offsetMs": -181, + "strength": "medium", + "description": "样例第 30 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 87.067, + "relativeTime": 0.4022833961706217, + "beatIndex": 163, + "phraseIndex": 20, + "nearestBeatSec": 87.321, + "offsetMs": -254, + "strength": "medium", + "description": "样例第 31 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 87.483, + "relativeTime": 0.40420547793302286, + "beatIndex": 163, + "phraseIndex": 20, + "nearestBeatSec": 87.321, + "offsetMs": 162, + "strength": "medium", + "description": "样例第 32 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 87.983, + "relativeTime": 0.4065156723589858, + "beatIndex": 164, + "phraseIndex": 20, + "nearestBeatSec": 87.857, + "offsetMs": 126, + "strength": "medium", + "description": "样例第 33 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 88.567, + "relativeTime": 0.4092139794485104, + "beatIndex": 165, + "phraseIndex": 20, + "nearestBeatSec": 88.393, + "offsetMs": 174, + "strength": "medium", + "description": "样例第 34 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 88.983, + "relativeTime": 0.41113606121091156, + "beatIndex": 166, + "phraseIndex": 20, + "nearestBeatSec": 88.929, + "offsetMs": 54, + "strength": "medium", + "description": "样例第 35 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 89.4, + "relativeTime": 0.41306276336216463, + "beatIndex": 167, + "phraseIndex": 20, + "nearestBeatSec": 89.464, + "offsetMs": -64, + "strength": "medium", + "description": "样例第 36 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 89.817, + "relativeTime": 0.4149894655134176, + "beatIndex": 168, + "phraseIndex": 21, + "nearestBeatSec": 90, + "offsetMs": -183, + "strength": "medium", + "description": "样例第 37 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 90.25, + "relativeTime": 0.4169900938863015, + "beatIndex": 168, + "phraseIndex": 21, + "nearestBeatSec": 90, + "offsetMs": 250, + "strength": "medium", + "description": "样例第 38 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 90.683, + "relativeTime": 0.4189907222591854, + "beatIndex": 169, + "phraseIndex": 21, + "nearestBeatSec": 90.536, + "offsetMs": 147, + "strength": "medium", + "description": "样例第 39 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 91.117, + "relativeTime": 0.4209959710209212, + "beatIndex": 170, + "phraseIndex": 21, + "nearestBeatSec": 91.071, + "offsetMs": 46, + "strength": "medium", + "description": "样例第 40 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 100.567, + "relativeTime": 0.46465864567161974, + "beatIndex": 188, + "phraseIndex": 23, + "nearestBeatSec": 100.714, + "offsetMs": -147, + "strength": "medium", + "description": "样例第 41 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 111.717, + "relativeTime": 0.5161759813705922, + "beatIndex": 209, + "phraseIndex": 26, + "nearestBeatSec": 111.964, + "offsetMs": -247, + "strength": "medium", + "description": "样例第 42 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 112.117, + "relativeTime": 0.5180241369113625, + "beatIndex": 209, + "phraseIndex": 26, + "nearestBeatSec": 111.964, + "offsetMs": 153, + "strength": "medium", + "description": "样例第 43 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 112.55, + "relativeTime": 0.5200247652842463, + "beatIndex": 210, + "phraseIndex": 26, + "nearestBeatSec": 112.5, + "offsetMs": 50, + "strength": "medium", + "description": "样例第 44 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 117.933, + "relativeTime": 0.5448963184741629, + "beatIndex": 220, + "phraseIndex": 27, + "nearestBeatSec": 117.857, + "offsetMs": 76, + "strength": "medium", + "description": "样例第 45 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 118.383, + "relativeTime": 0.5469754934575294, + "beatIndex": 221, + "phraseIndex": 27, + "nearestBeatSec": 118.393, + "offsetMs": -10, + "strength": "medium", + "description": "样例第 46 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 120.783, + "relativeTime": 0.5580644267021513, + "beatIndex": 225, + "phraseIndex": 28, + "nearestBeatSec": 120.536, + "offsetMs": 247, + "strength": "medium", + "description": "样例第 47 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 121.783, + "relativeTime": 0.5626848155540771, + "beatIndex": 227, + "phraseIndex": 28, + "nearestBeatSec": 121.607, + "offsetMs": 176, + "strength": "medium", + "description": "样例第 48 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 123.667, + "relativeTime": 0.5713896281511053, + "beatIndex": 231, + "phraseIndex": 28, + "nearestBeatSec": 123.75, + "offsetMs": -83, + "strength": "medium", + "description": "样例第 49 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 124.633, + "relativeTime": 0.5758529237820655, + "beatIndex": 233, + "phraseIndex": 29, + "nearestBeatSec": 124.821, + "offsetMs": -188, + "strength": "medium", + "description": "样例第 50 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 125.6, + "relativeTime": 0.5803208398018778, + "beatIndex": 234, + "phraseIndex": 29, + "nearestBeatSec": 125.357, + "offsetMs": 243, + "strength": "medium", + "description": "样例第 51 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 126.033, + "relativeTime": 0.5823214681747616, + "beatIndex": 235, + "phraseIndex": 29, + "nearestBeatSec": 125.893, + "offsetMs": 140, + "strength": "medium", + "description": "样例第 52 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 126.467, + "relativeTime": 0.5843267169364974, + "beatIndex": 236, + "phraseIndex": 29, + "nearestBeatSec": 126.429, + "offsetMs": 38, + "strength": "medium", + "description": "样例第 53 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 127.517, + "relativeTime": 0.5891781252310194, + "beatIndex": 238, + "phraseIndex": 29, + "nearestBeatSec": 127.5, + "offsetMs": 17, + "strength": "medium", + "description": "样例第 54 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 131.35, + "relativeTime": 0.606888075700451, + "beatIndex": 245, + "phraseIndex": 30, + "nearestBeatSec": 131.25, + "offsetMs": 100, + "strength": "medium", + "description": "样例第 55 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 133.133, + "relativeTime": 0.6151262290234347, + "beatIndex": 249, + "phraseIndex": 31, + "nearestBeatSec": 133.393, + "offsetMs": -260, + "strength": "medium", + "description": "样例第 56 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 140.681, + "relativeTime": 0.6500009240777704, + "beatIndex": 263, + "phraseIndex": 32, + "nearestBeatSec": 140.893, + "offsetMs": -212, + "segmentRole": "climax", + "strength": "medium", + "description": "高阶快切组合技巧输出" + }, + { + "eventType": "caption", + "timeSec": 194.789, + "relativeTime": 0.9000009240777703, + "beatIndex": 364, + "phraseIndex": 45, + "nearestBeatSec": 195, + "offsetMs": -211, + "segmentRole": "closing", + "strength": "medium", + "description": "实操场景收尾闭环" + }, + { + "eventType": "cut", + "timeSec": 197.85, + "relativeTime": 0.9141439343535153, + "beatIndex": 369, + "phraseIndex": 46, + "nearestBeatSec": 197.679, + "offsetMs": 171, + "strength": "strong", + "description": "样例第 57 个切镜点" + } + ], + "cutIntervalsSec": [ + 0.05, + 0.517, + 0.483, + 10.233, + 0.6, + 6.917, + 1.683, + 1, + 0.8, + 0.467, + 0.433, + 14.35, + 0.55, + 6.934, + 11.816, + 2.867, + 2.2, + 0.567, + 7.133, + 0.5, + 0.65, + 0.45, + 6.433, + 1.434, + 0.45, + 0.483, + 0.533, + 1, + 3.134, + 0.866, + 1.534, + 0.416, + 0.5, + 0.584, + 0.416, + 0.417, + 0.417, + 0.433, + 0.433, + 0.434, + 9.45, + 11.15, + 0.4, + 0.433, + 5.383, + 0.45, + 2.4, + 1, + 1.884, + 0.966, + 0.967, + 0.433, + 0.434, + 1.05, + 3.833, + 1.783, + 64.717, + 18.582 + ], + "captionStrategy": "字幕按段落信息点出现,避免漂浮徽章。", + "strategySummary": "样例节奏:58 镜,平均 3.7s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_e8ea3bd1-7981-40a6-aca6-678304133abc", + "source": "global_sample", + "durationSec": 216.432, + "sourceAspect": "1440:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1440:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 189 个,硬切 57 个。", + "真实音频 onset 24 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 0.5, + "relativeTime": 0.002, + "strength": "medium", + "energyDb": -10.631 + }, + { + "timeSec": 2, + "relativeTime": 0.009, + "strength": "strong", + "energyDb": -10.57 + }, + { + "timeSec": 3, + "relativeTime": 0.014, + "strength": "weak", + "energyDb": -11.284 + }, + { + "timeSec": 6, + "relativeTime": 0.028, + "strength": "weak", + "energyDb": -11.217 + }, + { + "timeSec": 8.5, + "relativeTime": 0.039, + "strength": "medium", + "energyDb": -10.993 + }, + { + "timeSec": 12, + "relativeTime": 0.055, + "strength": "strong", + "energyDb": -9.581 + }, + { + "timeSec": 18.5, + "relativeTime": 0.085, + "strength": "medium", + "energyDb": -10.948 + }, + { + "timeSec": 19.5, + "relativeTime": 0.09, + "strength": "medium", + "energyDb": -10.601 + }, + { + "timeSec": 21.5, + "relativeTime": 0.099, + "strength": "strong", + "energyDb": -10.356 + }, + { + "timeSec": 23, + "relativeTime": 0.106, + "strength": "weak", + "energyDb": -11.113 + }, + { + "timeSec": 28.5, + "relativeTime": 0.132, + "strength": "weak", + "energyDb": -11.289 + }, + { + "timeSec": 32.5, + "relativeTime": 0.15, + "strength": "weak", + "energyDb": -11.127 + }, + { + "timeSec": 34, + "relativeTime": 0.157, + "strength": "strong", + "energyDb": -10.512 + }, + { + "timeSec": 38.5, + "relativeTime": 0.178, + "strength": "strong", + "energyDb": -9.294 + }, + { + "timeSec": 40, + "relativeTime": 0.185, + "strength": "weak", + "energyDb": -11.363 + }, + { + "timeSec": 42.5, + "relativeTime": 0.196, + "strength": "strong", + "energyDb": -9.311 + }, + { + "timeSec": 44, + "relativeTime": 0.203, + "strength": "strong", + "energyDb": -9.529 + }, + { + "timeSec": 45.5, + "relativeTime": 0.21, + "strength": "medium", + "energyDb": -11.021 + }, + { + "timeSec": 54.5, + "relativeTime": 0.252, + "strength": "medium", + "energyDb": -10.928 + }, + { + "timeSec": 56, + "relativeTime": 0.259, + "strength": "medium", + "energyDb": -11.06 + }, + { + "timeSec": 57, + "relativeTime": 0.263, + "strength": "medium", + "energyDb": -10.852 + }, + { + "timeSec": 58.5, + "relativeTime": 0.27, + "strength": "medium", + "energyDb": -10.836 + }, + { + "timeSec": 60, + "relativeTime": 0.277, + "strength": "strong", + "energyDb": -10.549 + }, + { + "timeSec": 63, + "relativeTime": 0.291, + "strength": "weak", + "energyDb": -11.209 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 0.4, + "relativeTime": 0.002, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 0.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 0.5, + "relativeTime": 0.002, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 0.733, + "relativeTime": 0.003, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 2, + "relativeTime": 0.009, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 3, + "relativeTime": 0.014, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 6, + "relativeTime": 0.028, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 8.333, + "relativeTime": 0.039, + "strength": "weak", + "direction": "left", + "nearestOnsetSec": 8.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 8.5, + "relativeTime": 0.039, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 8.967, + "relativeTime": 0.041, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 9.333, + "relativeTime": 0.043, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 9.683, + "relativeTime": 0.045, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 10.05, + "relativeTime": 0.046, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 10.4, + "relativeTime": 0.048, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 10.733, + "relativeTime": 0.05, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 11.083, + "relativeTime": 0.051, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 11.483, + "relativeTime": 0.053, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 12, + "relativeTime": 0.055, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 12.2, + "relativeTime": 0.056, + "strength": "strong", + "direction": "left", + "nearestOnsetSec": 12, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 12.683, + "relativeTime": 0.059, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 13.05, + "relativeTime": 0.06, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 15.933, + "relativeTime": 0.074, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 16.283, + "relativeTime": 0.075, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 18.5, + "relativeTime": 0.085, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 19.5, + "relativeTime": 0.09, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 21.5, + "relativeTime": 0.099, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 23, + "relativeTime": 0.106, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 28.5, + "relativeTime": 0.132, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 32.5, + "relativeTime": 0.15, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "reveal_pan", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "动态快切视觉钩子开场 -> 受众定位与教学主题引入 -> 基础技巧拆解+配套素材资源展示 -> 高阶快切组合技巧输出 -> 实操场景收尾闭环", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "动态快切视觉钩子开场", + "durationRatio": 0.1, + "intent": "第一时间抓住剪辑学习者的注意力,建立内容的专业动感调性", + "copyPattern": "高动态拖影快切画面冲击开场,无冗余铺垫直接展示剪辑效果", + "watchingPurpose": "开场抓停:第一时间抓住剪辑学习者的注意力,建立内容的专业动感调性" + }, + { + "role": "setup", + "label": "受众定位与教学主题引入", + "durationRatio": 0.15, + "intent": "精准筛选目标受众,抛出本次教学的核心剪辑技巧主题", + "copyPattern": "直接点明目标受众的提升需求,快速引出快切剪辑的核心教学方向", + "watchingPurpose": "建立背景:精准筛选目标受众,抛出本次教学的核心剪辑技巧主题" + }, + { + "role": "develop", + "label": "基础技巧拆解+配套素材资源展示", + "durationRatio": 0.4, + "intent": "先拆解基础剪辑逻辑,再配套推荐可直接复用的实用素材资源降低学习门槛", + "copyPattern": "基础技巧点拆解+素材网站分类逐一展示,每类资源对应明确使用场景", + "watchingPurpose": "推进主体:先拆解基础剪辑逻辑,再配套推荐可直接复用的实用素材资源降低学习门槛" + }, + { + "role": "climax", + "label": "高阶快切组合技巧输出", + "durationRatio": 0.25, + "intent": "输出核心高阶剪辑方法论,强化内容的干货价值,让观众获得可直接落地的进阶技巧", + "copyPattern": "分点拆解快切动势匹配规则+收尾升格慢动作的搭配逻辑,用示例画面佐证效果", + "watchingPurpose": "放大重点:输出核心高阶剪辑方法论,强化内容的干货价值,让观众获得可直接落地的进阶技巧" + }, + { + "role": "closing", + "label": "实操场景收尾闭环", + "durationRatio": 0.1, + "intent": "落地到剪辑软件实操场景,强化观众的学习感知,完成内容收尾", + "copyPattern": "展示剪辑软件操作界面,关联前面讲解的所有技巧的实操场景", + "watchingPurpose": "收束记忆点:落地到剪辑软件实操场景,强化观众的学习感知,完成内容收尾" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 216.432, + "shotCount": 58, + "avgShotSec": 3.73, + "cutDensity": "medium", + "peakAt": 0.7, + "beatHints": [ + "轻节奏电子BGM卡点适配快切画面", + "技巧讲解段落鼓点加重突出重点", + "收尾段落BGM舒缓回落引导注意力集中到实操界面" + ], + "rhythmNotes": [ + "平均 3.7s/镜,整体为 中等节奏。", + "高潮位置约在全片 70%。" + ] + }, + "subtitleStyle": { + "density": "medium", + "placement": "中等密度字幕", + "typography": "与标题条风格「顶部半透明黑底白字短标题栏,仅展示当期教学核心关键词」协同", + "animation": "字幕/标题可能配合「以硬切快切为主,少量动态模糊转场匹配剪辑教学主题」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "顶部半透明黑底白字短标题栏,仅展示当期教学核心关键词", + "stickerUsage": "低频次使用,仅在素材分类节点添加高亮提示贴纸", + "coverStyle": "带核心技巧关键词+讲师人像的信息类封面,突出剪辑提升收益点", + "overlayStyle": "顶部半透明黑底白字短标题栏,仅展示当期教学核心关键词 / 低频次使用,仅在素材分类节点添加高亮提示贴纸", + "notes": [ + "画面包装迁移重点:顶部半透明黑底白字短标题栏,仅展示当期教学核心关键词;低频次使用,仅在素材分类节点添加高亮提示贴纸;带核心技巧关键词+讲师人像的信息类封面,突出剪辑提升收益点", + "模板画幅:letterbox_frame,viewport=1440:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "以硬切快切为主,少量动态模糊转场匹配剪辑教学主题", + "frequency": "中等频率切换", + "notableTransitions": [ + "以硬切快切为主,少量动态模糊转场匹配剪辑教学主题" + ], + "executableTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "snap_cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻节奏电子BGM卡点适配快切画面", + "技巧讲解段落鼓点加重突出重点", + "收尾段落BGM舒缓回落引导注意力集中到实操界面" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 20, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "setup", + "requiredAssetTypes": [ + "talking_head" + ], + "minDurationSec": 32, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "develop", + "requiredAssetTypes": [ + "usage_demo", + "text_card", + "b_roll" + ], + "minDurationSec": 86, + "optional": false + }, + { + "slotId": "slot_004", + "segmentRole": "climax", + "requiredAssetTypes": [ + "usage_demo", + "comparison" + ], + "minDurationSec": 54, + "optional": false + }, + { + "slotId": "slot_005", + "segmentRole": "closing", + "requiredAssetTypes": [ + "product_closeup" + ], + "minDurationSec": 22, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 216.432s", + "ref": "seed:v0300fg10000d8clavfog65lllqg9efg.MP4" + }, + { + "type": "resolution", + "detail": "1440x720 @ 59.598fps" + }, + { + "type": "scene_cut", + "detail": "58 个镜头 / 57 个切点(原始 177 个,已合并 <0.4s 密集检测)", + "ref": "0.05, 0.57, 1.05, 11.28, 11.88, 18.80, 20.48, 21.48, 22.28, 22.75, 23.18, 37.53, 38.08, 45.02, 56.83, 59.70, 61.90, 62.47, 69.60, 70.10, 70.75, 71.20, 77.63, 79.07, 79.52, 80.00, 80.53, 81.53, 84.67, 85.53, 87.07, 87.48, 87.98, 88.57, 88.98, 89.40, 89.82, 90.25, 90.68, 91.12, 100.57, 111.72, 112.12, 112.55, 117.93, 118.38, 120.78, 121.78, 123.67, 124.63, 125.60, 126.03, 126.47, 127.52, 131.35, 133.13, 197.85" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 medium;模板事件 28 个;音频 onset 24 个", + "ref": "letterbox_frame, reveal_pan, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构从强视觉冲击的快切开场快速留住对剪辑感兴趣的用户,第一时间筛选精准受众,再循序渐进从基础技巧到配套实用资源再到高阶组合方法论,最后落地到实操场景完成内容闭环,节奏适配教学类内容的信息接收规律,既避免用户中途走神,又通过实用资源推荐提升内容的实用价值,最大化观众的干货吸收效率。", + "createdAt": "2026-06-08T10:09:26.155Z", + "updatedAt": "2026-06-08T10:11:50.292Z" + }, + { + "id": "pattern_72213dd1", + "scope": "global", + "sourceSampleId": "ba842143-2b04-4306-9cc5-8ed43db5cda0", + "name": "v2700fgi0000d82osgfog65u7s2acpvg · 展示模式", + "summary": "该样例是 22s 的 展示 视频,结构为 纪实场景开场 -> 拍摄前置铺垫 -> 饮品细节轮播 -> 创意特效高光 -> 账号引流引导,节奏为 中等节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "full_story", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:纪实场景开场 -> 账号引流引导", + "formula": "纪实场景开场 -> 拍摄前置铺垫 -> 饮品细节轮播 -> 创意特效高光 -> 账号引流引导", + "source": { + "filename": "v2700fgi0000d82osgfog65u7s2acpvg.MP4", + "durationSec": 22.005, + "aspectRatio": "720:1280", + "shotCount": 10 + }, + "segments": [ + { + "role": "hook", + "label": "纪实场景开场", + "durationRatio": 0.1, + "intent": "快速建立饮品店创作场景的真实代入感", + "copyPattern": "创作者工作状态纪实开场", + "watchingPurpose": "开场抓停:快速建立饮品店创作场景的真实代入感" + }, + { + "role": "setup", + "label": "拍摄前置铺垫", + "durationRatio": 0.1, + "intent": "铺垫专业拍摄的仪式感,引出后续展示主体", + "copyPattern": "第一视角展示拍摄设备对准待拍摄主体", + "watchingPurpose": "建立背景:铺垫专业拍摄的仪式感,引出后续展示主体" + }, + { + "role": "develop", + "label": "饮品细节轮播", + "durationRatio": 0.4, + "intent": "多角度呈现分层冰饮的造型、色彩与质感细节", + "copyPattern": "多景别快速切换展示产品不同角度细节", + "watchingPurpose": "推进主体:多角度呈现分层冰饮的造型、色彩与质感细节" + }, + { + "role": "climax", + "label": "创意特效高光", + "durationRatio": 0.25, + "intent": "用特殊视觉效果强化饮品的氛围感与高级感,打造视觉峰值", + "copyPattern": "叠加炫光、水波纹等创意特效打造视觉冲击", + "watchingPurpose": "放大重点:用特殊视觉效果强化饮品的氛围感与高级感,打造视觉峰值" + }, + { + "role": "closing", + "label": "账号引流引导", + "durationRatio": 0.15, + "intent": "清晰传递账号信息,引导观众搜索关注创作者", + "copyPattern": "全屏展示账号搜索指引信息", + "watchingPurpose": "收束记忆点:清晰传递账号信息,引导观众搜索关注创作者" + } + ], + "pacing": { + "durationSec": 22.005, + "shotCount": 10, + "avgShotSec": 2.2, + "cutDensity": "medium", + "peakAt": 0.7, + "beatHints": [ + "轻快鼓点适配展示节奏", + "特效节点卡点重音", + "收尾处平缓收束" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "角落悬浮式账号标识,不遮挡主体画面", + "stickerUsage": "仅使用少量创作者专属艺术字logo作为点缀", + "transitionStyle": "硬切+创意特效转场结合,适配展示节奏", + "coverStyle": "高颜值分层冰饮特写搭配极简账号标识" + }, + "storySkeleton": { + "arcType": "展示 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "快速建立饮品店创作场景的真实代入感", + "铺垫专业拍摄的仪式感,引出后续展示主体", + "多角度呈现分层冰饮的造型、色彩与质感细节", + "用特殊视觉效果强化饮品的氛围感与高级感,打造视觉峰值", + "清晰传递账号信息,引导观众搜索关注创作者" + ], + "hookStyle": "创作者工作状态纪实开场", + "turnOrProofStyle": "多景别快速切换展示产品不同角度细节", + "payoffStyle": "全屏展示账号搜索指引信息", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "showcase" + ], + "assetRequirements": [ + "talking_head", + "b_roll", + "product_closeup", + "comparison", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "角落悬浮式账号标识,不遮挡主体画面", + "stickerUsage": "仅使用少量创作者专属艺术字logo作为点缀", + "coverStyle": "高颜值分层冰饮特写搭配极简账号标识", + "overlayStyle": "角落悬浮式账号标识,不遮挡主体画面 / 仅使用少量创作者专属艺术字logo作为点缀", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 22.005, + "bpm": 112, + "beatCount": 41, + "beatStability": 0.5313475760065736, + "onsetDensity": 0.4544421722335833, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 3.961, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143 + ] + }, + { + "startSec": 3.961, + "endSec": 12.103, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 4.286, + 6.429, + 8.571, + 10.714 + ] + }, + { + "startSec": 12.103, + "endSec": 16.064, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 12.857, + 15 + ] + }, + { + "startSec": 16.064, + "endSec": 22.005, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 17.143, + 19.286, + 21.429 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_ba842143-2b04-4306-9cc5-8ed43db5cda0", + "source": "global_sample", + "durationSec": 22.005, + "music": { + "hasAudio": true, + "durationSec": 22.005, + "bpm": 112, + "beatCount": 41, + "beatStability": 0.5313475760065736, + "onsetDensity": 0.4544421722335833, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 3.961, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143 + ] + }, + { + "startSec": 3.961, + "endSec": 12.103, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 4.286, + 6.429, + 8.571, + 10.714 + ] + }, + { + "startSec": 12.103, + "endSec": 16.064, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 12.857, + 15 + ] + }, + { + "startSec": 16.064, + "endSec": 22.005, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 17.143, + 19.286, + 21.429 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 10, + "avgShotSec": 2.2, + "peakAt": 0.7, + "cutEveryBeats": 4.543, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "纪实场景开场" + }, + { + "eventType": "cut", + "timeSec": 1.3, + "relativeTime": 0.05907748239036583, + "beatIndex": 2, + "phraseIndex": 0, + "nearestBeatSec": 1.071, + "offsetMs": 229, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 2.2, + "relativeTime": 0.09997727789138833, + "beatIndex": 4, + "phraseIndex": 0, + "nearestBeatSec": 2.143, + "offsetMs": 57, + "segmentRole": "setup", + "strength": "medium", + "description": "拍摄前置铺垫" + }, + { + "eventType": "cut", + "timeSec": 3.133, + "relativeTime": 0.14237673256078165, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 3.214, + "offsetMs": -81, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 4.401, + "relativeTime": 0.2, + "beatIndex": 8, + "phraseIndex": 1, + "nearestBeatSec": 4.286, + "offsetMs": 115, + "segmentRole": "develop", + "strength": "medium", + "description": "饮品细节轮播" + }, + { + "eventType": "cut", + "timeSec": 5.567, + "relativeTime": 0.25298795728243584, + "beatIndex": 10, + "phraseIndex": 1, + "nearestBeatSec": 5.357, + "offsetMs": 210, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 6, + "relativeTime": 0.27266530334015, + "beatIndex": 11, + "phraseIndex": 1, + "nearestBeatSec": 5.893, + "offsetMs": 107, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 6.433, + "relativeTime": 0.29234264939786414, + "beatIndex": 12, + "phraseIndex": 1, + "nearestBeatSec": 6.429, + "offsetMs": 4, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.7, + "relativeTime": 0.5316973415132924, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 11.786, + "offsetMs": -86, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 13.203, + "relativeTime": 0.6, + "beatIndex": 25, + "phraseIndex": 3, + "nearestBeatSec": 13.393, + "offsetMs": -190, + "segmentRole": "climax", + "strength": "medium", + "description": "创意特效高光" + }, + { + "eventType": "cut", + "timeSec": 14.333, + "relativeTime": 0.6513519654623949, + "beatIndex": 27, + "phraseIndex": 3, + "nearestBeatSec": 14.464, + "offsetMs": -131, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 17.667, + "relativeTime": 0.8028629856850716, + "beatIndex": 33, + "phraseIndex": 4, + "nearestBeatSec": 17.679, + "offsetMs": -12, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 18.704, + "relativeTime": 0.8499886389456942, + "beatIndex": 35, + "phraseIndex": 4, + "nearestBeatSec": 18.75, + "offsetMs": -46, + "segmentRole": "closing", + "strength": "medium", + "description": "账号引流引导" + }, + { + "eventType": "cut", + "timeSec": 18.967, + "relativeTime": 0.8619404680754373, + "beatIndex": 35, + "phraseIndex": 4, + "nearestBeatSec": 18.75, + "offsetMs": 217, + "strength": "strong", + "description": "样例第 9 个切镜点" + } + ], + "cutIntervalsSec": [ + 1.3, + 1.833, + 2.434, + 0.433, + 0.433, + 5.267, + 2.633, + 3.334, + 1.3, + 3.038 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:10 镜,平均 2.2s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_ba842143-2b04-4306-9cc5-8ed43db5cda0", + "source": "global_sample", + "durationSec": 22.005, + "sourceAspect": "720:1280", + "targetCanvasAspect": "9:16", + "layoutPreset": "full_bleed", + "frameStyle": { + "backgroundColor": "#050505", + "matte": false, + "roundedMask": false, + "labelStyle": "none", + "viewport": { + "aspectRatio": "720:1280", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": false, + "hasViewportSlides": false, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 14 个,硬切 9 个。", + "真实音频 onset 7 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 3, + "relativeTime": 0.136, + "strength": "strong", + "energyDb": -2.801 + }, + { + "timeSec": 5, + "relativeTime": 0.227, + "strength": "strong", + "energyDb": -3.785 + }, + { + "timeSec": 7.5, + "relativeTime": 0.341, + "strength": "weak", + "energyDb": -3.962 + }, + { + "timeSec": 8.5, + "relativeTime": 0.386, + "strength": "medium", + "energyDb": -3.83 + }, + { + "timeSec": 12, + "relativeTime": 0.545, + "strength": "strong", + "energyDb": -3.088 + }, + { + "timeSec": 14, + "relativeTime": 0.636, + "strength": "weak", + "energyDb": -4.022 + }, + { + "timeSec": 15.5, + "relativeTime": 0.704, + "strength": "weak", + "energyDb": -3.948 + } + ], + "events": [ + { + "kind": "internal_motion", + "timeSec": 2.6, + "relativeTime": 0.118, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 3, + "relativeTime": 0.136, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 3.767, + "relativeTime": 0.171, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 4.433, + "relativeTime": 0.201, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 5, + "relativeTime": 0.227, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 5.1, + "relativeTime": 0.232, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 5, + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 7.5, + "relativeTime": 0.341, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 8.5, + "relativeTime": 0.386, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 9.733, + "relativeTime": 0.442, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 11.067, + "relativeTime": 0.503, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 12, + "relativeTime": 0.545, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 12.133, + "relativeTime": 0.551, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 12, + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 13, + "relativeTime": 0.591, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 13.667, + "relativeTime": 0.621, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 14, + "relativeTime": 0.636, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 15, + "relativeTime": 0.682, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 15.5, + "relativeTime": 0.704, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 15.6, + "relativeTime": 0.709, + "strength": "weak", + "direction": "unknown", + "nearestOnsetSec": 15.5, + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 16.333, + "relativeTime": 0.742, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 17.5, + "relativeTime": 0.795, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 17.833, + "relativeTime": 0.81, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + } + ], + "strategySummary": "迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。", + "renderHints": [ + "full_bleed", + "reveal_pan", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "纪实场景开场 -> 拍摄前置铺垫 -> 饮品细节轮播 -> 创意特效高光 -> 账号引流引导", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "纪实场景开场", + "durationRatio": 0.1, + "intent": "快速建立饮品店创作场景的真实代入感", + "copyPattern": "创作者工作状态纪实开场", + "watchingPurpose": "开场抓停:快速建立饮品店创作场景的真实代入感" + }, + { + "role": "setup", + "label": "拍摄前置铺垫", + "durationRatio": 0.1, + "intent": "铺垫专业拍摄的仪式感,引出后续展示主体", + "copyPattern": "第一视角展示拍摄设备对准待拍摄主体", + "watchingPurpose": "建立背景:铺垫专业拍摄的仪式感,引出后续展示主体" + }, + { + "role": "develop", + "label": "饮品细节轮播", + "durationRatio": 0.4, + "intent": "多角度呈现分层冰饮的造型、色彩与质感细节", + "copyPattern": "多景别快速切换展示产品不同角度细节", + "watchingPurpose": "推进主体:多角度呈现分层冰饮的造型、色彩与质感细节" + }, + { + "role": "climax", + "label": "创意特效高光", + "durationRatio": 0.25, + "intent": "用特殊视觉效果强化饮品的氛围感与高级感,打造视觉峰值", + "copyPattern": "叠加炫光、水波纹等创意特效打造视觉冲击", + "watchingPurpose": "放大重点:用特殊视觉效果强化饮品的氛围感与高级感,打造视觉峰值" + }, + { + "role": "closing", + "label": "账号引流引导", + "durationRatio": 0.15, + "intent": "清晰传递账号信息,引导观众搜索关注创作者", + "copyPattern": "全屏展示账号搜索指引信息", + "watchingPurpose": "收束记忆点:清晰传递账号信息,引导观众搜索关注创作者" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 22.005, + "shotCount": 10, + "avgShotSec": 2.2, + "cutDensity": "medium", + "peakAt": 0.7, + "beatHints": [ + "轻快鼓点适配展示节奏", + "特效节点卡点重音", + "收尾处平缓收束" + ], + "rhythmNotes": [ + "平均 2.2s/镜,整体为 中等节奏。", + "高潮位置约在全片 70%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「角落悬浮式账号标识,不遮挡主体画面」协同", + "animation": "字幕/标题可能配合「硬切+创意特效转场结合,适配展示节奏」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "角落悬浮式账号标识,不遮挡主体画面", + "stickerUsage": "仅使用少量创作者专属艺术字logo作为点缀", + "coverStyle": "高颜值分层冰饮特写搭配极简账号标识", + "overlayStyle": "角落悬浮式账号标识,不遮挡主体画面 / 仅使用少量创作者专属艺术字logo作为点缀", + "notes": [ + "画面包装迁移重点:角落悬浮式账号标识,不遮挡主体画面;仅使用少量创作者专属艺术字logo作为点缀;高颜值分层冰饮特写搭配极简账号标识", + "模板画幅:full_bleed,viewport=720:1280,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "硬切+创意特效转场结合,适配展示节奏", + "frequency": "中等频率切换", + "notableTransitions": [ + "硬切+创意特效转场结合,适配展示节奏" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻快鼓点适配展示节奏", + "特效节点卡点重音", + "收尾处平缓收束" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "s1", + "segmentRole": "hook", + "requiredAssetTypes": [ + "talking_head", + "b_roll" + ], + "minDurationSec": 2, + "optional": false + }, + { + "slotId": "s2", + "segmentRole": "develop", + "requiredAssetTypes": [ + "product_closeup", + "b_roll" + ], + "minDurationSec": 8, + "optional": false + }, + { + "slotId": "s3", + "segmentRole": "climax", + "requiredAssetTypes": [ + "product_closeup", + "comparison" + ], + "minDurationSec": 5, + "optional": false + }, + { + "slotId": "s4", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 22.005s", + "ref": "seed:v2700fgi0000d82osgfog65u7s2acpvg.MP4" + }, + { + "type": "resolution", + "detail": "720x1280 @ 30fps" + }, + { + "type": "scene_cut", + "detail": "10 个镜头 / 9 个切点(原始 9 个,已合并 <0.4s 密集检测)", + "ref": "1.30, 3.13, 5.57, 6.00, 6.43, 11.70, 14.33, 17.67, 18.97" + }, + { + "type": "template_profile", + "detail": "full_bleed;内部运动 medium;模板事件 21 个;音频 onset 7 个", + "ref": "full_bleed, reveal_pan, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 10 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构从真实的创作者拍摄工作场景切入,快速建立观众代入感,随后通过多镜头多角度的饮品细节展示逐步提升视觉吸引力,在视频后段通过创意特效打造视觉高潮,最后直接给出清晰的账号关注指引,全程节奏紧凑适配短视频用户的碎片化浏览习惯,用差异化的创意特效强化饮品的高级质感,避免长时间单调展示引发的审美疲劳,最终实现内容展示与账号引流的双重目标。", + "createdAt": "2026-06-08T10:11:04.148Z", + "updatedAt": "2026-06-08T10:12:05.227Z" + }, + { + "id": "pattern_16d008d2", + "scope": "global", + "sourceSampleId": "cef2f9ac-4694-419a-bf6d-46ae16a41f7c", + "name": "v2800fgi0000d6r22pnog65sbqkhhqv0 · Vlog模式", + "summary": "该样例是 23s 的 Vlog 视频,结构为 第一视角出行锚定 -> 在地特色初体验 -> 沿途碎片蒙太奇 -> 创意视觉情绪峰值 -> 首尾呼应闭环收尾,节奏为 中等节奏。", + "videoGenre": "vlog", + "tags": [ + "vlog", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "full_story", + "ctaType": "generic_next_action", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "weak", + "recommendedUse": "secondary_story", + "warnings": [ + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "Vlog:第一视角出行锚定 -> 首尾呼应闭环收尾", + "formula": "第一视角出行锚定 -> 在地特色初体验 -> 沿途碎片蒙太奇 -> 创意视觉情绪峰值 -> 首尾呼应闭环收尾", + "source": { + "filename": "v2800fgi0000d6r22pnog65sbqkhhqv0.MP4", + "durationSec": 23.497, + "aspectRatio": "720:960", + "shotCount": 12 + }, + "segments": [ + { + "role": "hook", + "label": "第一视角出行锚定", + "durationRatio": 0.13, + "intent": "快速把观众代入旅行出发的临场状态,建立第一人称视角的沉浸感", + "copyPattern": "第一人称俯视脚步视角直接切入,搭配主题文字点明旅行目的地", + "watchingPurpose": "开场抓停:快速把观众代入旅行出发的临场状态,建立第一人称视角的沉浸感" + }, + { + "role": "setup", + "label": "在地特色初体验", + "durationRatio": 0.1, + "intent": "铺垫目的地慢节奏休闲的整体气质,给观众建立初步的城市印象", + "copyPattern": "标志性在地饮品特写切入,传递松弛的旅行氛围感", + "watchingPurpose": "建立背景:铺垫目的地慢节奏休闲的整体气质,给观众建立初步的城市印象" + }, + { + "role": "develop", + "label": "沿途碎片蒙太奇", + "durationRatio": 0.52, + "intent": "集中输出大量目的地特色场景,快速展示旅行途中的多元所见", + "copyPattern": "无旁白的碎片化随拍拼接,穿插复古胶片滤镜强化文艺调性", + "watchingPurpose": "推进主体:集中输出大量目的地特色场景,快速展示旅行途中的多元所见" + }, + { + "role": "climax", + "label": "创意视觉情绪峰值", + "durationRatio": 0.17, + "intent": "用特殊视觉特效把旅行的松弛情绪推到最高点,打造记忆点", + "copyPattern": "高饱和分色嵌套画框的创意特效,突出画面的艺术感", + "watchingPurpose": "放大重点:用特殊视觉特效把旅行的松弛情绪推到最高点,打造记忆点" + }, + { + "role": "closing", + "label": "首尾呼应闭环收尾", + "durationRatio": 0.08, + "intent": "完成旅行片段的叙事闭环,留下治愈余韵", + "copyPattern": "回到开场的第一人称地面视角,叠加旅行记忆碎片贴纸完成呼应", + "watchingPurpose": "收束记忆点:完成旅行片段的叙事闭环,留下治愈余韵" + } + ], + "pacing": { + "durationSec": 23.497, + "shotCount": 12, + "avgShotSec": 1.96, + "cutDensity": "medium", + "peakAt": 0.6, + "beatHints": [ + "复古轻节奏鼓点", + "镜头切换卡点对齐鼓点", + "后半段旋律上扬烘托松弛情绪" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "底部固定文艺风小字主题条,全程不遮挡核心画面", + "stickerUsage": "低饱和度半透明几何/头像贴纸点缀画面,仅收尾处叠加记忆碎片贴纸", + "transitionStyle": "快切为主,搭配少量黑场过渡、特效转场,无生硬跳转", + "coverStyle": "第一视角脚步特写+目的地主题文字的氛围感封面,突出旅行松弛感" + }, + "storySkeleton": { + "arcType": "Vlog / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "快速把观众代入旅行出发的临场状态,建立第一人称视角的沉浸感", + "铺垫目的地慢节奏休闲的整体气质,给观众建立初步的城市印象", + "集中输出大量目的地特色场景,快速展示旅行途中的多元所见", + "用特殊视觉特效把旅行的松弛情绪推到最高点,打造记忆点", + "完成旅行片段的叙事闭环,留下治愈余韵" + ], + "hookStyle": "第一人称俯视脚步视角直接切入,搭配主题文字点明旅行目的地", + "turnOrProofStyle": "无旁白的碎片化随拍拼接,穿插复古胶片滤镜强化文艺调性", + "payoffStyle": "回到开场的第一人称地面视角,叠加旅行记忆碎片贴纸完成呼应", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "vlog" + ], + "assetRequirements": [ + "b_roll", + "product_closeup", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "snap_cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "high", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "底部固定文艺风小字主题条,全程不遮挡核心画面", + "stickerUsage": "低饱和度半透明几何/头像贴纸点缀画面,仅收尾处叠加记忆碎片贴纸", + "coverStyle": "第一视角脚步特写+目的地主题文字的氛围感封面,突出旅行松弛感", + "overlayStyle": "底部固定文艺风小字主题条,全程不遮挡核心画面 / 低饱和度半透明几何/头像贴纸点缀画面,仅收尾处叠加记忆碎片贴纸", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 23.497, + "bpm": 112, + "beatCount": 44, + "beatStability": 0, + "onsetDensity": 0.5107034940630719, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 4.229, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143 + ] + }, + { + "startSec": 4.229, + "endSec": 12.923, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 4.286, + 6.429, + 8.571, + 10.714, + 12.857 + ] + }, + { + "startSec": 12.923, + "endSec": 17.152, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 15, + 17.143 + ] + }, + { + "startSec": 17.152, + "endSec": 23.497, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 19.286, + 21.429 + ] + } + ], + "tags": [ + "vlog", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_cef2f9ac-4694-419a-bf6d-46ae16a41f7c", + "source": "global_sample", + "durationSec": 23.497, + "music": { + "hasAudio": true, + "durationSec": 23.497, + "bpm": 112, + "beatCount": 44, + "beatStability": 0, + "onsetDensity": 0.5107034940630719, + "energyShape": "rising", + "peakAt": 0.6, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429 + ], + "sections": [ + { + "startSec": 0, + "endSec": 4.229, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143 + ] + }, + { + "startSec": 4.229, + "endSec": 12.923, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 4.286, + 6.429, + 8.571, + 10.714, + 12.857 + ] + }, + { + "startSec": 12.923, + "endSec": 17.152, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 15, + 17.143 + ] + }, + { + "startSec": 17.152, + "endSec": 23.497, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 19.286, + 21.429 + ] + } + ], + "tags": [ + "vlog", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 12, + "avgShotSec": 1.96, + "peakAt": 0.6, + "cutEveryBeats": 1.928, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "第一视角出行锚定" + }, + { + "eventType": "cut", + "timeSec": 2.967, + "relativeTime": 0.12627143890709452, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 3.214, + "offsetMs": -247, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 3.055, + "relativeTime": 0.13001659786355707, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 3.214, + "offsetMs": -159, + "segmentRole": "setup", + "strength": "medium", + "description": "在地特色初体验" + }, + { + "eventType": "cut", + "timeSec": 5.3, + "relativeTime": 0.22556070987785673, + "beatIndex": 10, + "phraseIndex": 1, + "nearestBeatSec": 5.357, + "offsetMs": -57, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 5.405, + "relativeTime": 0.23002936545090863, + "beatIndex": 10, + "phraseIndex": 1, + "nearestBeatSec": 5.357, + "offsetMs": 48, + "segmentRole": "develop", + "strength": "medium", + "description": "沿途碎片蒙太奇" + }, + { + "eventType": "cut", + "timeSec": 6.2, + "relativeTime": 0.26386347193258713, + "beatIndex": 12, + "phraseIndex": 1, + "nearestBeatSec": 6.429, + "offsetMs": -229, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 6.65, + "relativeTime": 0.2830148529599523, + "beatIndex": 12, + "phraseIndex": 1, + "nearestBeatSec": 6.429, + "offsetMs": 221, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 7.2, + "relativeTime": 0.30642209643784313, + "beatIndex": 13, + "phraseIndex": 1, + "nearestBeatSec": 6.964, + "offsetMs": 236, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 8, + "relativeTime": 0.34046899604204794, + "beatIndex": 15, + "phraseIndex": 1, + "nearestBeatSec": 8.036, + "offsetMs": -36, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.733, + "relativeTime": 0.4993403413201686, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 11.786, + "offsetMs": -53, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 12.233, + "relativeTime": 0.5206196535727966, + "beatIndex": 23, + "phraseIndex": 2, + "nearestBeatSec": 12.321, + "offsetMs": -88, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 12.9, + "relativeTime": 0.5490062561178023, + "beatIndex": 24, + "phraseIndex": 3, + "nearestBeatSec": 12.857, + "offsetMs": 43, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 13.933, + "relativeTime": 0.5929693152317317, + "beatIndex": 26, + "phraseIndex": 3, + "nearestBeatSec": 13.929, + "offsetMs": 4, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 17.623, + "relativeTime": 0.7500106396561264, + "beatIndex": 33, + "phraseIndex": 4, + "nearestBeatSec": 17.679, + "offsetMs": -56, + "segmentRole": "climax", + "strength": "medium", + "description": "创意视觉情绪峰值" + }, + { + "eventType": "cut", + "timeSec": 20.267, + "relativeTime": 0.8625356428480231, + "beatIndex": 38, + "phraseIndex": 4, + "nearestBeatSec": 20.357, + "offsetMs": -90, + "strength": "strong", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 21.617, + "relativeTime": 0.9199897859301188, + "beatIndex": 40, + "phraseIndex": 5, + "nearestBeatSec": 21.429, + "offsetMs": 188, + "segmentRole": "closing", + "strength": "medium", + "description": "首尾呼应闭环收尾" + } + ], + "cutIntervalsSec": [ + 2.967, + 2.333, + 0.9, + 0.45, + 0.55, + 0.8, + 3.733, + 0.5, + 0.667, + 1.033, + 6.334, + 3.23 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:12 镜,平均 2.0s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_cef2f9ac-4694-419a-bf6d-46ae16a41f7c", + "source": "global_sample", + "durationSec": 23.497, + "sourceAspect": "720:960", + "targetCanvasAspect": "9:16", + "layoutPreset": "full_bleed", + "frameStyle": { + "backgroundColor": "#050505", + "matte": false, + "roundedMask": false, + "labelStyle": "none", + "viewport": { + "aspectRatio": "688:928", + "x": 0.022, + "y": 0.017, + "width": 0.956, + "height": 0.967 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": false, + "hasViewportSlides": false, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 24 个,硬切 11 个。", + "真实音频 onset 10 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 1, + "relativeTime": 0.043, + "strength": "strong", + "energyDb": -15.348 + }, + { + "timeSec": 2.5, + "relativeTime": 0.106, + "strength": "medium", + "energyDb": -16.187 + }, + { + "timeSec": 3.5, + "relativeTime": 0.149, + "strength": "weak", + "energyDb": -16.992 + }, + { + "timeSec": 6, + "relativeTime": 0.255, + "strength": "strong", + "energyDb": -15.557 + }, + { + "timeSec": 9.5, + "relativeTime": 0.404, + "strength": "strong", + "energyDb": -15.187 + }, + { + "timeSec": 11, + "relativeTime": 0.468, + "strength": "strong", + "energyDb": -15.846 + }, + { + "timeSec": 14.5, + "relativeTime": 0.617, + "strength": "strong", + "energyDb": -15.3 + }, + { + "timeSec": 16, + "relativeTime": 0.681, + "strength": "medium", + "energyDb": -16.323 + }, + { + "timeSec": 18, + "relativeTime": 0.766, + "strength": "medium", + "energyDb": -16.334 + }, + { + "timeSec": 19.5, + "relativeTime": 0.83, + "strength": "medium", + "energyDb": -16.299 + } + ], + "events": [ + { + "kind": "internal_motion", + "timeSec": 0.233, + "relativeTime": 0.01, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 0.567, + "relativeTime": 0.024, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 0.983, + "relativeTime": 0.042, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 1, + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 1, + "relativeTime": 0.043, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 1.317, + "relativeTime": 0.056, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 1.667, + "relativeTime": 0.071, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 2, + "relativeTime": 0.085, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 2.5, + "relativeTime": 0.106, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 2.517, + "relativeTime": 0.107, + "strength": "weak", + "direction": "unknown", + "nearestOnsetSec": 2.5, + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 3.5, + "relativeTime": 0.149, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 3.8, + "relativeTime": 0.162, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 4.733, + "relativeTime": 0.201, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 5.067, + "relativeTime": 0.216, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 5.983, + "relativeTime": 0.255, + "strength": "strong", + "direction": "unknown", + "nearestOnsetSec": 6, + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 6, + "relativeTime": 0.255, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 7.533, + "relativeTime": 0.321, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 8.217, + "relativeTime": 0.35, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 8.55, + "relativeTime": 0.364, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 8.9, + "relativeTime": 0.379, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 9.5, + "relativeTime": 0.404, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 10.083, + "relativeTime": 0.429, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 11, + "relativeTime": 0.468, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 14.5, + "relativeTime": 0.617, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 16, + "relativeTime": 0.681, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 18, + "relativeTime": 0.766, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 19.5, + "relativeTime": 0.83, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。", + "renderHints": [ + "full_bleed", + "reveal_pan", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "第一视角出行锚定 -> 在地特色初体验 -> 沿途碎片蒙太奇 -> 创意视觉情绪峰值 -> 首尾呼应闭环收尾", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "第一视角出行锚定", + "durationRatio": 0.13, + "intent": "快速把观众代入旅行出发的临场状态,建立第一人称视角的沉浸感", + "copyPattern": "第一人称俯视脚步视角直接切入,搭配主题文字点明旅行目的地", + "watchingPurpose": "开场抓停:快速把观众代入旅行出发的临场状态,建立第一人称视角的沉浸感" + }, + { + "role": "setup", + "label": "在地特色初体验", + "durationRatio": 0.1, + "intent": "铺垫目的地慢节奏休闲的整体气质,给观众建立初步的城市印象", + "copyPattern": "标志性在地饮品特写切入,传递松弛的旅行氛围感", + "watchingPurpose": "建立背景:铺垫目的地慢节奏休闲的整体气质,给观众建立初步的城市印象" + }, + { + "role": "develop", + "label": "沿途碎片蒙太奇", + "durationRatio": 0.52, + "intent": "集中输出大量目的地特色场景,快速展示旅行途中的多元所见", + "copyPattern": "无旁白的碎片化随拍拼接,穿插复古胶片滤镜强化文艺调性", + "watchingPurpose": "推进主体:集中输出大量目的地特色场景,快速展示旅行途中的多元所见" + }, + { + "role": "climax", + "label": "创意视觉情绪峰值", + "durationRatio": 0.17, + "intent": "用特殊视觉特效把旅行的松弛情绪推到最高点,打造记忆点", + "copyPattern": "高饱和分色嵌套画框的创意特效,突出画面的艺术感", + "watchingPurpose": "放大重点:用特殊视觉特效把旅行的松弛情绪推到最高点,打造记忆点" + }, + { + "role": "closing", + "label": "首尾呼应闭环收尾", + "durationRatio": 0.08, + "intent": "完成旅行片段的叙事闭环,留下治愈余韵", + "copyPattern": "回到开场的第一人称地面视角,叠加旅行记忆碎片贴纸完成呼应", + "watchingPurpose": "收束记忆点:完成旅行片段的叙事闭环,留下治愈余韵" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 23.497, + "shotCount": 12, + "avgShotSec": 1.96, + "cutDensity": "medium", + "peakAt": 0.6, + "beatHints": [ + "复古轻节奏鼓点", + "镜头切换卡点对齐鼓点", + "后半段旋律上扬烘托松弛情绪" + ], + "rhythmNotes": [ + "平均 2.0s/镜,整体为 中等节奏。", + "高潮位置约在全片 60%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「底部固定文艺风小字主题条,全程不遮挡核心画面」协同", + "animation": "字幕/标题可能配合「快切为主,搭配少量黑场过渡、特效转场,无生硬跳转」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "底部固定文艺风小字主题条,全程不遮挡核心画面", + "stickerUsage": "低饱和度半透明几何/头像贴纸点缀画面,仅收尾处叠加记忆碎片贴纸", + "coverStyle": "第一视角脚步特写+目的地主题文字的氛围感封面,突出旅行松弛感", + "overlayStyle": "底部固定文艺风小字主题条,全程不遮挡核心画面 / 低饱和度半透明几何/头像贴纸点缀画面,仅收尾处叠加记忆碎片贴纸", + "notes": [ + "画面包装迁移重点:底部固定文艺风小字主题条,全程不遮挡核心画面;低饱和度半透明几何/头像贴纸点缀画面,仅收尾处叠加记忆碎片贴纸;第一视角脚步特写+目的地主题文字的氛围感封面,突出旅行松弛感", + "模板画幅:full_bleed,viewport=688:928,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "快切为主,搭配少量黑场过渡、特效转场,无生硬跳转", + "frequency": "中等频率切换", + "notableTransitions": [ + "快切为主,搭配少量黑场过渡、特效转场,无生硬跳转" + ], + "executableTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "snap_cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "复古轻节奏鼓点", + "镜头切换卡点对齐鼓点", + "后半段旋律上扬烘托松弛情绪" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "s1", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 2, + "optional": false + }, + { + "slotId": "s2", + "segmentRole": "setup", + "requiredAssetTypes": [ + "product_closeup", + "b_roll" + ], + "minDurationSec": 2, + "optional": false + }, + { + "slotId": "s3", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 10, + "optional": false + }, + { + "slotId": "s4", + "segmentRole": "climax", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 3, + "optional": false + }, + { + "slotId": "s5", + "segmentRole": "closing", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 2, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 23.497s", + "ref": "seed:v2800fgi0000d6r22pnog65sbqkhhqv0.MP4" + }, + { + "type": "resolution", + "detail": "720x960 @ 56.285fps" + }, + { + "type": "scene_cut", + "detail": "12 个镜头 / 11 个切点(原始 17 个,已合并 <0.4s 密集检测)", + "ref": "2.97, 5.30, 6.20, 6.65, 7.20, 8.00, 11.73, 12.23, 12.90, 13.93, 20.27" + }, + { + "type": "template_profile", + "detail": "full_bleed;内部运动 medium;模板事件 26 个;音频 onset 10 个", + "ref": "full_bleed, reveal_pan, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构从第一人称脚步开场快速把观众代入旅行出发的沉浸状态,先用在地特色饮品铺垫目的地慢节奏的整体气质,再用无旁白的碎片化蒙太奇集中输出大量特色街景内容,避免冗长叙事适配短视频用户的快刷习惯,中间用创意特效画面拉高情绪峰值打造记忆点,最后首尾呼应回到开场视角形成完整叙事闭环,全程低信息密度的视觉流搭配复古调性,精准传递随性治愈的旅行体验,满足用户刷取旅行类放松内容的核心需求。", + "createdAt": "2026-06-08T10:12:06.782Z", + "updatedAt": "2026-06-08T10:12:10.510Z" + }, + { + "id": "pattern_f994d946", + "scope": "global", + "sourceSampleId": "4d9ce58c-3692-49da-8a4f-1c4dce44a428", + "name": "v0d00fg10000d7u96jvog65p575fl740 · 展示模式", + "summary": "该样例是 28s 的 展示 视频,结构为 氛围感开篇切入 -> 核心意象铺垫 -> 全场景展开 -> 情绪峰值强化 -> 平台导流收尾,节奏为 中等节奏。", + "videoGenre": "showcase", + "tags": [ + "showcase", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "full_story", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "weak", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "展示:氛围感开篇切入 -> 平台导流收尾", + "formula": "氛围感开篇切入 -> 核心意象铺垫 -> 全场景展开 -> 情绪峰值强化 -> 平台导流收尾", + "source": { + "filename": "v0d00fg10000d7u96jvog65p575fl740.MP4", + "durationSec": 28.328, + "aspectRatio": "1280:720", + "shotCount": 14 + }, + "segments": [ + { + "role": "hook", + "label": "氛围感开篇切入", + "durationRatio": 0.1, + "intent": "第一时间抓住观众注意力,快速点明樱花季主题", + "copyPattern": "框景式核心景观直接开场,无冗余铺垫", + "watchingPurpose": "开场抓停:第一时间抓住观众注意力,快速点明樱花季主题" + }, + { + "role": "setup", + "label": "核心意象铺垫", + "durationRatio": 0.2, + "intent": "建立樱花季与城市日常的关联认知", + "copyPattern": "樱花元素+城市地标类画面快速切换输出", + "watchingPurpose": "建立背景:建立樱花季与城市日常的关联认知" + }, + { + "role": "develop", + "label": "全场景展开", + "durationRatio": 0.4, + "intent": "丰富樱花季的内容维度,强化治愈浪漫的整体氛围", + "copyPattern": "串联自然景观、人文活动、休闲场景多类内容逐层铺陈", + "watchingPurpose": "推进主体:丰富樱花季的内容维度,强化治愈浪漫的整体氛围" + }, + { + "role": "climax", + "label": "情绪峰值强化", + "durationRatio": 0.2, + "intent": "将浪漫治愈的观感推到最高点", + "copyPattern": "用落英、波光等动态细节放大氛围感", + "watchingPurpose": "放大重点:将浪漫治愈的观感推到最高点" + }, + { + "role": "closing", + "label": "平台导流收尾", + "durationRatio": 0.1, + "intent": "完成内容分发后的跳转引导", + "copyPattern": "固定平台导流卡片直接呈现操作指引", + "watchingPurpose": "收束记忆点:完成内容分发后的跳转引导" + } + ], + "pacing": { + "durationSec": 28.328, + "shotCount": 14, + "avgShotSec": 2.02, + "cutDensity": "medium", + "peakAt": 0.65, + "beatHints": [ + "轻缓日系钢琴基底节拍", + "画面切换卡点对齐鼓点", + "情绪上扬节点适配旋律升调" + ] + }, + "packaging": { + "subtitleDensity": "medium", + "titleBarStyle": "左上角固定展示账号ID与平台标识", + "stickerUsage": "无额外装饰贴纸,仅保留原生平台水印标识", + "transitionStyle": "全程硬切无多余特效,靠画面内容自然衔接", + "coverStyle": "樱花枝框景河景搭配账号ID的清新治愈风封面" + }, + "storySkeleton": { + "arcType": "展示 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "第一时间抓住观众注意力,快速点明樱花季主题", + "建立樱花季与城市日常的关联认知", + "丰富樱花季的内容维度,强化治愈浪漫的整体氛围", + "将浪漫治愈的观感推到最高点", + "完成内容分发后的跳转引导" + ], + "hookStyle": "框景式核心景观直接开场,无冗余铺垫", + "turnOrProofStyle": "串联自然景观、人文活动、休闲场景多类内容逐层铺陈", + "payoffStyle": "固定平台导流卡片直接呈现操作指引", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "showcase" + ], + "assetRequirements": [ + "b_roll", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "左上角固定展示账号ID与平台标识", + "stickerUsage": "无额外装饰贴纸,仅保留原生平台水印标识", + "coverStyle": "樱花枝框景河景搭配账号ID的清新治愈风封面", + "overlayStyle": "左上角固定展示账号ID与平台标识 / 无额外装饰贴纸,仅保留原生平台水印标识", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 28.328, + "bpm": 112, + "beatCount": 53, + "beatStability": 0.6810101194274775, + "onsetDensity": 0.49421067495057897, + "energyShape": "rising", + "peakAt": 0.65, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714 + ], + "sections": [ + { + "startSec": 0, + "endSec": 5.099, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286 + ] + }, + { + "startSec": 5.099, + "endSec": 15.58, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 6.429, + 8.571, + 10.714, + 12.857, + 15 + ] + }, + { + "startSec": 15.58, + "endSec": 20.679, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 17.143, + 19.286 + ] + }, + { + "startSec": 20.679, + "endSec": 28.328, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 21.429, + 23.571, + 25.714, + 27.857 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_4d9ce58c-3692-49da-8a4f-1c4dce44a428", + "source": "global_sample", + "durationSec": 28.328, + "music": { + "hasAudio": true, + "durationSec": 28.328, + "bpm": 112, + "beatCount": 53, + "beatStability": 0.6810101194274775, + "onsetDensity": 0.49421067495057897, + "energyShape": "rising", + "peakAt": 0.65, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143, + 19.286, + 21.429, + 23.571, + 25.714, + 27.857 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143, + 21.429, + 25.714 + ], + "sections": [ + { + "startSec": 0, + "endSec": 5.099, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143, + 4.286 + ] + }, + { + "startSec": 5.099, + "endSec": 15.58, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 6.429, + 8.571, + 10.714, + 12.857, + 15 + ] + }, + { + "startSec": 15.58, + "endSec": 20.679, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 17.143, + 19.286 + ] + }, + { + "startSec": 20.679, + "endSec": 28.328, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 21.429, + 23.571, + 25.714, + 27.857 + ] + } + ], + "tags": [ + "showcase", + "density:medium", + "bpm:112", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 14, + "avgShotSec": 2.02, + "peakAt": 0.65, + "cutEveryBeats": 2.925, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "氛围感开篇切入" + }, + { + "eventType": "cut", + "timeSec": 1.533, + "relativeTime": 0.054116068907088394, + "beatIndex": 3, + "phraseIndex": 0, + "nearestBeatSec": 1.607, + "offsetMs": -74, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 2.833, + "relativeTime": 0.1000070601524993, + "beatIndex": 5, + "phraseIndex": 0, + "nearestBeatSec": 2.679, + "offsetMs": 154, + "segmentRole": "setup", + "strength": "medium", + "description": "核心意象铺垫" + }, + { + "eventType": "cut", + "timeSec": 3.767, + "relativeTime": 0.1329779723242022, + "beatIndex": 7, + "phraseIndex": 0, + "nearestBeatSec": 3.75, + "offsetMs": 17, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 5.333, + "relativeTime": 0.18825896639367412, + "beatIndex": 10, + "phraseIndex": 1, + "nearestBeatSec": 5.357, + "offsetMs": -24, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 6.9, + "relativeTime": 0.2435752612256425, + "beatIndex": 13, + "phraseIndex": 1, + "nearestBeatSec": 6.964, + "offsetMs": -64, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 8.4, + "relativeTime": 0.2965264049703474, + "beatIndex": 16, + "phraseIndex": 2, + "nearestBeatSec": 8.571, + "offsetMs": -171, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 8.499, + "relativeTime": 0.3000211804574979, + "beatIndex": 16, + "phraseIndex": 2, + "nearestBeatSec": 8.571, + "offsetMs": -72, + "segmentRole": "develop", + "strength": "medium", + "description": "全场景展开" + }, + { + "eventType": "cut", + "timeSec": 9.933, + "relativeTime": 0.35064247387743575, + "beatIndex": 19, + "phraseIndex": 2, + "nearestBeatSec": 10.179, + "offsetMs": -246, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.5, + "relativeTime": 0.4059587687094041, + "beatIndex": 21, + "phraseIndex": 2, + "nearestBeatSec": 11.25, + "offsetMs": 250, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 13.067, + "relativeTime": 0.4612750635413725, + "beatIndex": 24, + "phraseIndex": 3, + "nearestBeatSec": 12.857, + "offsetMs": 210, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 14.533, + "relativeTime": 0.5130259813611974, + "beatIndex": 27, + "phraseIndex": 3, + "nearestBeatSec": 14.464, + "offsetMs": 69, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 17.7, + "relativeTime": 0.6248234961875176, + "beatIndex": 33, + "phraseIndex": 4, + "nearestBeatSec": 17.679, + "offsetMs": 21, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 19.233, + "relativeTime": 0.6789395650946061, + "beatIndex": 36, + "phraseIndex": 4, + "nearestBeatSec": 19.286, + "offsetMs": -53, + "strength": "medium", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 19.83, + "relativeTime": 0.7000141203049985, + "beatIndex": 37, + "phraseIndex": 4, + "nearestBeatSec": 19.821, + "offsetMs": 9, + "segmentRole": "climax", + "strength": "medium", + "description": "情绪峰值强化" + }, + { + "eventType": "cut", + "timeSec": 20.767, + "relativeTime": 0.7330909347641908, + "beatIndex": 39, + "phraseIndex": 4, + "nearestBeatSec": 20.893, + "offsetMs": -126, + "strength": "medium", + "description": "样例第 12 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 25.3, + "relativeTime": 0.8931092911606892, + "beatIndex": 47, + "phraseIndex": 5, + "nearestBeatSec": 25.179, + "offsetMs": 121, + "strength": "strong", + "description": "样例第 13 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 25.496, + "relativeTime": 0.9000282406099972, + "beatIndex": 48, + "phraseIndex": 6, + "nearestBeatSec": 25.714, + "offsetMs": -218, + "segmentRole": "closing", + "strength": "medium", + "description": "平台导流收尾" + } + ], + "cutIntervalsSec": [ + 1.533, + 2.234, + 1.566, + 1.567, + 1.5, + 1.533, + 1.567, + 1.567, + 1.466, + 3.167, + 1.533, + 1.534, + 4.533, + 3.028 + ], + "captionStrategy": "字幕按段落信息点出现,避免漂浮徽章。", + "strategySummary": "样例节奏:14 镜,平均 2.0s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_4d9ce58c-3692-49da-8a4f-1c4dce44a428", + "source": "global_sample", + "durationSec": 28.328, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 6 个,硬切 13 个。", + "真实音频 onset 9 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 3, + "relativeTime": 0.106, + "strength": "strong", + "energyDb": -11.096 + }, + { + "timeSec": 4.5, + "relativeTime": 0.159, + "strength": "medium", + "energyDb": -11.75 + }, + { + "timeSec": 7.5, + "relativeTime": 0.265, + "strength": "strong", + "energyDb": -11.43 + }, + { + "timeSec": 9, + "relativeTime": 0.318, + "strength": "medium", + "energyDb": -11.653 + }, + { + "timeSec": 11.5, + "relativeTime": 0.406, + "strength": "medium", + "energyDb": -11.543 + }, + { + "timeSec": 13, + "relativeTime": 0.459, + "strength": "strong", + "energyDb": -10.865 + }, + { + "timeSec": 17.5, + "relativeTime": 0.618, + "strength": "strong", + "energyDb": -10.743 + }, + { + "timeSec": 21.5, + "relativeTime": 0.759, + "strength": "medium", + "energyDb": -11.872 + }, + { + "timeSec": 23, + "relativeTime": 0.812, + "strength": "weak", + "energyDb": -12.025 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 0.267, + "relativeTime": 0.009, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 3, + "relativeTime": 0.106, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 4.5, + "relativeTime": 0.159, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 7.5, + "relativeTime": 0.265, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 9, + "relativeTime": 0.318, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 11.5, + "relativeTime": 0.406, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 13, + "relativeTime": 0.459, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 16.1, + "relativeTime": 0.568, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 16.5, + "relativeTime": 0.582, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 16.867, + "relativeTime": 0.595, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 17.5, + "relativeTime": 0.618, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 21.5, + "relativeTime": 0.759, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 22.267, + "relativeTime": 0.786, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 23, + "relativeTime": 0.812, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 23.767, + "relativeTime": 0.839, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "ken_burns_in", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "氛围感开篇切入 -> 核心意象铺垫 -> 全场景展开 -> 情绪峰值强化 -> 平台导流收尾", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "氛围感开篇切入", + "durationRatio": 0.1, + "intent": "第一时间抓住观众注意力,快速点明樱花季主题", + "copyPattern": "框景式核心景观直接开场,无冗余铺垫", + "watchingPurpose": "开场抓停:第一时间抓住观众注意力,快速点明樱花季主题" + }, + { + "role": "setup", + "label": "核心意象铺垫", + "durationRatio": 0.2, + "intent": "建立樱花季与城市日常的关联认知", + "copyPattern": "樱花元素+城市地标类画面快速切换输出", + "watchingPurpose": "建立背景:建立樱花季与城市日常的关联认知" + }, + { + "role": "develop", + "label": "全场景展开", + "durationRatio": 0.4, + "intent": "丰富樱花季的内容维度,强化治愈浪漫的整体氛围", + "copyPattern": "串联自然景观、人文活动、休闲场景多类内容逐层铺陈", + "watchingPurpose": "推进主体:丰富樱花季的内容维度,强化治愈浪漫的整体氛围" + }, + { + "role": "climax", + "label": "情绪峰值强化", + "durationRatio": 0.2, + "intent": "将浪漫治愈的观感推到最高点", + "copyPattern": "用落英、波光等动态细节放大氛围感", + "watchingPurpose": "放大重点:将浪漫治愈的观感推到最高点" + }, + { + "role": "closing", + "label": "平台导流收尾", + "durationRatio": 0.1, + "intent": "完成内容分发后的跳转引导", + "copyPattern": "固定平台导流卡片直接呈现操作指引", + "watchingPurpose": "收束记忆点:完成内容分发后的跳转引导" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 28.328, + "shotCount": 14, + "avgShotSec": 2.02, + "cutDensity": "medium", + "peakAt": 0.65, + "beatHints": [ + "轻缓日系钢琴基底节拍", + "画面切换卡点对齐鼓点", + "情绪上扬节点适配旋律升调" + ], + "rhythmNotes": [ + "平均 2.0s/镜,整体为 中等节奏。", + "高潮位置约在全片 65%。" + ] + }, + "subtitleStyle": { + "density": "medium", + "placement": "中等密度字幕", + "typography": "与标题条风格「左上角固定展示账号ID与平台标识」协同", + "animation": "字幕/标题可能配合「全程硬切无多余特效,靠画面内容自然衔接」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "左上角固定展示账号ID与平台标识", + "stickerUsage": "无额外装饰贴纸,仅保留原生平台水印标识", + "coverStyle": "樱花枝框景河景搭配账号ID的清新治愈风封面", + "overlayStyle": "左上角固定展示账号ID与平台标识 / 无额外装饰贴纸,仅保留原生平台水印标识", + "notes": [ + "画面包装迁移重点:左上角固定展示账号ID与平台标识;无额外装饰贴纸,仅保留原生平台水印标识;樱花枝框景河景搭配账号ID的清新治愈风封面", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "全程硬切无多余特效,靠画面内容自然衔接", + "frequency": "中等频率切换", + "notableTransitions": [ + "全程硬切无多余特效,靠画面内容自然衔接" + ], + "executableTechniques": [ + { + "id": "tech_transition_cut", + "name": "可执行转场:cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻缓日系钢琴基底节拍", + "画面切换卡点对齐鼓点", + "情绪上扬节点适配旋律升调" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot-001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 1.5, + "optional": false + }, + { + "slotId": "slot-002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 10, + "optional": false + }, + { + "slotId": "slot-003", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 28.328s", + "ref": "seed:v0d00fg10000d7u96jvog65p575fl740.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 30fps" + }, + { + "type": "scene_cut", + "detail": "14 个镜头 / 13 个切点(原始 13 个,已合并 <0.4s 密集检测)", + "ref": "1.53, 3.77, 5.33, 6.90, 8.40, 9.93, 11.50, 13.07, 14.53, 17.70, 19.23, 20.77, 25.30" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 low;模板事件 15 个;音频 onset 9 个", + "ref": "letterbox_frame, ken_burns_in, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构适配氛围感景观展示类内容的观众观看习惯,开篇直接用高美感场景抓眼球避免冗余铺垫,中段多维度铺陈樱花相关的自然、人文画面逐步堆叠浪漫治愈情绪,在全片约2/3位置用最有氛围感的动态细节镜头将情绪推至峰值,最后用导流卡片完成转化,中等密度的快切节奏贴合轻缓BGM节拍,全程不会让观众产生审美疲劳,最大化传递内容的治愈属性。", + "createdAt": "2026-06-08T09:50:42.097Z", + "updatedAt": "2026-06-08T10:17:03.283Z" + }, + { + "id": "pattern_5cd1a067", + "scope": "global", + "sourceSampleId": "ddc00649-36fc-4b8d-9b62-d4f743d9596f", + "name": "v0d00fg10000d12foenog65g3j49gbe0 · 教程模式", + "summary": "该样例是 74s 的 教程 视频,结构为 核心主题开门见山 -> 高能效果初步预览 -> 多风格高能案例混剪 -> 顶级特效亮点强化 -> 账号引流引导,节奏为 快节奏。", + "videoGenre": "tutorial", + "tags": [ + "tutorial", + "high", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "story_candidate", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "教程:核心主题开门见山 -> 账号引流引导", + "formula": "核心主题开门见山 -> 高能效果初步预览 -> 多风格高能案例混剪 -> 顶级特效亮点强化 -> 账号引流引导", + "source": { + "filename": "v0d00fg10000d12foenog65g3j49gbe0.MP4", + "durationSec": 74.025, + "aspectRatio": "1280:720", + "shotCount": 37 + }, + "segments": [ + { + "role": "hook", + "label": "核心主题开门见山", + "durationRatio": 0.1, + "intent": "第一时间点明剪辑类教程主题,精准抓取目标受众注意力", + "copyPattern": "直给式抛出核心创作主题,无冗余铺垫", + "watchingPurpose": "开场抓停:第一时间点明剪辑类教程主题,精准抓取目标受众注意力" + }, + { + "role": "setup", + "label": "高能效果初步预览", + "durationRatio": 0.1, + "intent": "展示首个高辨识度高能画面,建立观众对最终产出效果的初步期待", + "copyPattern": "单样片快速露出,锚定内容风格调性", + "watchingPurpose": "建立背景:展示首个高辨识度高能画面,建立观众对最终产出效果的初步期待" + }, + { + "role": "develop", + "label": "多风格高能案例混剪", + "durationRatio": 0.6, + "intent": "批量覆盖实拍、动画、特效等不同品类的高能剪辑成片片段,直观呈现教程可实现的效果多样性", + "copyPattern": "高密度卡点混剪多维度参考案例,用视觉冲击力持续留住观众", + "watchingPurpose": "推进主体:批量覆盖实拍、动画、特效等不同品类的高能剪辑成片片段,直观呈现教程可实现的效果多样性" + }, + { + "role": "climax", + "label": "顶级特效亮点强化", + "durationRatio": 0.15, + "intent": "集中输出最具视觉冲击力的炸裂特效画面,最大化强化观众对教程价值的感知", + "copyPattern": "高冲击力特效片段集中输出,搭配情绪引导slogan放大爽感", + "watchingPurpose": "放大重点:集中输出最具视觉冲击力的炸裂特效画面,最大化强化观众对教程价值的感知" + }, + { + "role": "closing", + "label": "账号引流引导", + "durationRatio": 0.05, + "intent": "清晰告知观众获取完整教程的路径,完成私域/账号引流转化", + "copyPattern": "明确展示账号ID+搜索路径,降低用户行动门槛", + "watchingPurpose": "收束记忆点:清晰告知观众获取完整教程的路径,完成私域/账号引流转化" + } + ], + "pacing": { + "durationSec": 74.025, + "shotCount": 37, + "avgShotSec": 2, + "cutDensity": "high", + "peakAt": 0.7, + "beatHints": [ + "快节奏鼓点卡点匹配镜头切换", + "特效音同步画面飞溅/炸开动作", + "收尾平缓提示音配合引流页面展示" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "全屏大字体无遮挡醒目主题字", + "stickerUsage": "仅右下角固定放置创作者账号水印贴纸,不遮挡核心画面", + "transitionStyle": "闪切/快切为主的卡点转场,匹配BGM鼓点节奏", + "coverStyle": "黑底白字核心主题+最高能特效帧拼接的高对比度封面" + }, + "storySkeleton": { + "arcType": "教程 / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "第一时间点明剪辑类教程主题,精准抓取目标受众注意力", + "展示首个高辨识度高能画面,建立观众对最终产出效果的初步期待", + "批量覆盖实拍、动画、特效等不同品类的高能剪辑成片片段,直观呈现教程可实现的效果多样性", + "集中输出最具视觉冲击力的炸裂特效画面,最大化强化观众对教程价值的感知", + "清晰告知观众获取完整教程的路径,完成私域/账号引流转化" + ], + "hookStyle": "直给式抛出核心创作主题,无冗余铺垫", + "turnOrProofStyle": "高密度卡点混剪多维度参考案例,用视觉冲击力持续留住观众", + "payoffStyle": "明确展示账号ID+搜索路径,降低用户行动门槛", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "tutorial" + ], + "assetRequirements": [ + "text_card", + "b_roll", + "usage_demo" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=high 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "snap_cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "high", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_beat_pulse", + "name": "图片素材运镜:beat_pulse", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "beat_pulse", + "beatPlacement": "on estimated beat grid", + "intensity": "high", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=beat_pulse,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "全屏大字体无遮挡醒目主题字", + "stickerUsage": "仅右下角固定放置创作者账号水印贴纸,不遮挡核心画面", + "coverStyle": "黑底白字核心主题+最高能特效帧拼接的高对比度封面", + "overlayStyle": "全屏大字体无遮挡醒目主题字 / 仅右下角固定放置创作者账号水印贴纸,不遮挡核心画面", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "visual cuts align to dense beat grid", + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 74.025, + "bpm": 128, + "beatCount": 158, + "beatStability": 0.12469460986410141, + "onsetDensity": 0.4998311381290104, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625, + 7.5, + 9.375, + 11.25, + 13.125, + 15, + 16.875, + 18.75, + 20.625, + 22.5, + 24.375, + 26.25, + 28.125, + 30, + 31.875, + 33.75, + 35.625, + 37.5, + 39.375, + 41.25, + 43.125, + 45, + 46.875, + 48.75, + 50.625, + 52.5, + 54.375, + 56.25, + 58.125, + 60, + 61.875, + 63.75, + 65.625, + 67.5, + 69.375, + 71.25, + 73.125 + ], + "phraseBoundariesSec": [ + 0, + 3.75, + 7.5, + 11.25, + 15, + 18.75, + 22.5, + 26.25, + 30, + 33.75, + 37.5, + 41.25, + 45, + 48.75, + 52.5, + 56.25, + 60, + 63.75, + 67.5, + 71.25 + ], + "sections": [ + { + "startSec": 0, + "endSec": 13.325, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625, + 7.5, + 9.375, + 11.25, + 13.125 + ] + }, + { + "startSec": 13.325, + "endSec": 40.714, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 15, + 16.875, + 18.75, + 20.625, + 22.5, + 24.375, + 26.25, + 28.125, + 30, + 31.875, + 33.75, + 35.625, + 37.5, + 39.375 + ] + }, + { + "startSec": 40.714, + "endSec": 54.038, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 41.25, + 43.125, + 45, + 46.875, + 48.75, + 50.625, + 52.5 + ] + }, + { + "startSec": 54.038, + "endSec": 74.025, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 54.375, + 56.25, + 58.125, + 60, + 61.875, + 63.75, + 65.625, + 67.5, + 69.375, + 71.25, + 73.125 + ] + } + ], + "tags": [ + "tutorial", + "density:high", + "bpm:128", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_ddc00649-36fc-4b8d-9b62-d4f743d9596f", + "source": "global_sample", + "durationSec": 74.025, + "music": { + "hasAudio": true, + "durationSec": 74.025, + "bpm": 128, + "beatCount": 158, + "beatStability": 0.12469460986410141, + "onsetDensity": 0.4998311381290104, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625, + 7.5, + 9.375, + 11.25, + 13.125, + 15, + 16.875, + 18.75, + 20.625, + 22.5, + 24.375, + 26.25, + 28.125, + 30, + 31.875, + 33.75, + 35.625, + 37.5, + 39.375, + 41.25, + 43.125, + 45, + 46.875, + 48.75, + 50.625, + 52.5, + 54.375, + 56.25, + 58.125, + 60, + 61.875, + 63.75, + 65.625, + 67.5, + 69.375, + 71.25, + 73.125 + ], + "phraseBoundariesSec": [ + 0, + 3.75, + 7.5, + 11.25, + 15, + 18.75, + 22.5, + 26.25, + 30, + 33.75, + 37.5, + 41.25, + 45, + 48.75, + 52.5, + 56.25, + 60, + 63.75, + 67.5, + 71.25 + ], + "sections": [ + { + "startSec": 0, + "endSec": 13.325, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625, + 7.5, + 9.375, + 11.25, + 13.125 + ] + }, + { + "startSec": 13.325, + "endSec": 40.714, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 15, + 16.875, + 18.75, + 20.625, + 22.5, + 24.375, + 26.25, + 28.125, + 30, + 31.875, + 33.75, + 35.625, + 37.5, + 39.375 + ] + }, + { + "startSec": 40.714, + "endSec": 54.038, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 41.25, + 43.125, + 45, + 46.875, + 48.75, + 50.625, + 52.5 + ] + }, + { + "startSec": 54.038, + "endSec": 74.025, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 54.375, + 56.25, + 58.125, + 60, + 61.875, + 63.75, + 65.625, + 67.5, + 69.375, + 71.25, + 73.125 + ] + } + ], + "tags": [ + "tutorial", + "density:high", + "bpm:128", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "high", + "shotCount": 37, + "avgShotSec": 2, + "peakAt": 0.7, + "cutEveryBeats": 3.021, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "核心主题开门见山" + }, + { + "eventType": "cut", + "timeSec": 3, + "relativeTime": 0.04052684903748733, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 2.813, + "offsetMs": 187, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 7.25, + "relativeTime": 0.09793988517392772, + "beatIndex": 15, + "phraseIndex": 1, + "nearestBeatSec": 7.031, + "offsetMs": 219, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 7.403, + "relativeTime": 0.10000675447483957, + "beatIndex": 16, + "phraseIndex": 2, + "nearestBeatSec": 7.5, + "offsetMs": -97, + "segmentRole": "setup", + "strength": "medium", + "description": "高能效果初步预览" + }, + { + "eventType": "cut", + "timeSec": 8.125, + "relativeTime": 0.10976021614319485, + "beatIndex": 17, + "phraseIndex": 2, + "nearestBeatSec": 7.969, + "offsetMs": 156, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 8.625, + "relativeTime": 0.11651469098277609, + "beatIndex": 18, + "phraseIndex": 2, + "nearestBeatSec": 8.438, + "offsetMs": 187, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 9.708, + "relativeTime": 0.131144883485309, + "beatIndex": 21, + "phraseIndex": 2, + "nearestBeatSec": 9.844, + "offsetMs": -136, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.875, + "relativeTime": 0.16041877744005403, + "beatIndex": 25, + "phraseIndex": 3, + "nearestBeatSec": 11.719, + "offsetMs": 156, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 13, + "relativeTime": 0.1756163458291118, + "beatIndex": 28, + "phraseIndex": 3, + "nearestBeatSec": 13.125, + "offsetMs": -125, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 14.806, + "relativeTime": 0.20001350894967915, + "beatIndex": 32, + "phraseIndex": 4, + "nearestBeatSec": 15, + "offsetMs": -194, + "segmentRole": "develop", + "strength": "medium", + "description": "多风格高能案例混剪" + }, + { + "eventType": "cut", + "timeSec": 14.958, + "relativeTime": 0.20206686930091183, + "beatIndex": 32, + "phraseIndex": 4, + "nearestBeatSec": 15, + "offsetMs": -42, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 15.583, + "relativeTime": 0.21050996285038837, + "beatIndex": 33, + "phraseIndex": 4, + "nearestBeatSec": 15.469, + "offsetMs": 114, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 16.042, + "relativeTime": 0.21671057075312394, + "beatIndex": 34, + "phraseIndex": 4, + "nearestBeatSec": 15.938, + "offsetMs": 104, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 16.458, + "relativeTime": 0.2223302938196555, + "beatIndex": 35, + "phraseIndex": 4, + "nearestBeatSec": 16.406, + "offsetMs": 52, + "strength": "medium", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 19.042, + "relativeTime": 0.25723741979061127, + "beatIndex": 41, + "phraseIndex": 5, + "nearestBeatSec": 19.219, + "offsetMs": -177, + "strength": "medium", + "description": "样例第 12 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 20.458, + "relativeTime": 0.2763660925363053, + "beatIndex": 44, + "phraseIndex": 5, + "nearestBeatSec": 20.625, + "offsetMs": -167, + "strength": "medium", + "description": "样例第 13 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 21.083, + "relativeTime": 0.28480918608578176, + "beatIndex": 45, + "phraseIndex": 5, + "nearestBeatSec": 21.094, + "offsetMs": -11, + "strength": "medium", + "description": "样例第 14 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 22.125, + "relativeTime": 0.2988855116514691, + "beatIndex": 47, + "phraseIndex": 5, + "nearestBeatSec": 22.031, + "offsetMs": 94, + "strength": "medium", + "description": "样例第 15 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 23.958, + "relativeTime": 0.3236474164133738, + "beatIndex": 51, + "phraseIndex": 6, + "nearestBeatSec": 23.906, + "offsetMs": 52, + "strength": "medium", + "description": "样例第 16 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 30.083, + "relativeTime": 0.4063897331982438, + "beatIndex": 64, + "phraseIndex": 8, + "nearestBeatSec": 30, + "offsetMs": 83, + "strength": "medium", + "description": "样例第 17 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 32.042, + "relativeTime": 0.43285376561972305, + "beatIndex": 68, + "phraseIndex": 8, + "nearestBeatSec": 31.875, + "offsetMs": 167, + "strength": "medium", + "description": "样例第 18 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 41.333, + "relativeTime": 0.5583654170888213, + "beatIndex": 88, + "phraseIndex": 11, + "nearestBeatSec": 41.25, + "offsetMs": 83, + "strength": "medium", + "description": "样例第 19 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 44.167, + "relativeTime": 0.5966497804795677, + "beatIndex": 94, + "phraseIndex": 11, + "nearestBeatSec": 44.063, + "offsetMs": 104, + "strength": "medium", + "description": "样例第 20 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 44.833, + "relativeTime": 0.6056467409658899, + "beatIndex": 96, + "phraseIndex": 12, + "nearestBeatSec": 45, + "offsetMs": -167, + "strength": "medium", + "description": "样例第 21 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 45.333, + "relativeTime": 0.612401215805471, + "beatIndex": 97, + "phraseIndex": 12, + "nearestBeatSec": 45.469, + "offsetMs": -136, + "strength": "medium", + "description": "样例第 22 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 48.125, + "relativeTime": 0.6501182033096926, + "beatIndex": 103, + "phraseIndex": 12, + "nearestBeatSec": 48.281, + "offsetMs": -156, + "strength": "medium", + "description": "样例第 23 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 50, + "relativeTime": 0.6754474839581222, + "beatIndex": 107, + "phraseIndex": 13, + "nearestBeatSec": 50.156, + "offsetMs": -156, + "strength": "medium", + "description": "样例第 24 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 51.583, + "relativeTime": 0.6968321513002363, + "beatIndex": 110, + "phraseIndex": 13, + "nearestBeatSec": 51.563, + "offsetMs": 20, + "strength": "medium", + "description": "样例第 25 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 55.833, + "relativeTime": 0.7542451874366767, + "beatIndex": 119, + "phraseIndex": 14, + "nearestBeatSec": 55.781, + "offsetMs": 52, + "strength": "medium", + "description": "样例第 26 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 57.708, + "relativeTime": 0.7795744680851063, + "beatIndex": 123, + "phraseIndex": 15, + "nearestBeatSec": 57.656, + "offsetMs": 52, + "strength": "medium", + "description": "样例第 27 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 58.333, + "relativeTime": 0.7880175616345828, + "beatIndex": 124, + "phraseIndex": 15, + "nearestBeatSec": 58.125, + "offsetMs": 208, + "strength": "medium", + "description": "样例第 28 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 59.125, + "relativeTime": 0.7987166497804795, + "beatIndex": 126, + "phraseIndex": 15, + "nearestBeatSec": 59.063, + "offsetMs": 62, + "strength": "medium", + "description": "样例第 29 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 59.221, + "relativeTime": 0.800013508949679, + "beatIndex": 126, + "phraseIndex": 15, + "nearestBeatSec": 59.063, + "offsetMs": 158, + "segmentRole": "climax", + "strength": "medium", + "description": "顶级特效亮点强化" + }, + { + "eventType": "cut", + "timeSec": 59.875, + "relativeTime": 0.8088483620398513, + "beatIndex": 128, + "phraseIndex": 16, + "nearestBeatSec": 60, + "offsetMs": -125, + "strength": "medium", + "description": "样例第 30 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 61, + "relativeTime": 0.8240459304289091, + "beatIndex": 130, + "phraseIndex": 16, + "nearestBeatSec": 60.938, + "offsetMs": 62, + "strength": "medium", + "description": "样例第 31 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 61.75, + "relativeTime": 0.8341776426882809, + "beatIndex": 132, + "phraseIndex": 16, + "nearestBeatSec": 61.875, + "offsetMs": -125, + "strength": "medium", + "description": "样例第 32 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 62.5, + "relativeTime": 0.8443093549476528, + "beatIndex": 133, + "phraseIndex": 16, + "nearestBeatSec": 62.344, + "offsetMs": 156, + "strength": "medium", + "description": "样例第 33 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 63.167, + "relativeTime": 0.8533198243836542, + "beatIndex": 135, + "phraseIndex": 16, + "nearestBeatSec": 63.281, + "offsetMs": -114, + "strength": "medium", + "description": "样例第 34 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 65.583, + "relativeTime": 0.8859574468085105, + "beatIndex": 140, + "phraseIndex": 17, + "nearestBeatSec": 65.625, + "offsetMs": -42, + "strength": "medium", + "description": "样例第 35 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 70.325, + "relativeTime": 0.9500168861870989, + "beatIndex": 150, + "phraseIndex": 18, + "nearestBeatSec": 70.313, + "offsetMs": 12, + "segmentRole": "closing", + "strength": "medium", + "description": "账号引流引导" + }, + { + "eventType": "cut", + "timeSec": 71, + "relativeTime": 0.9591354272205336, + "beatIndex": 151, + "phraseIndex": 18, + "nearestBeatSec": 70.781, + "offsetMs": 219, + "strength": "strong", + "description": "样例第 36 个切镜点" + } + ], + "cutIntervalsSec": [ + 3, + 4.25, + 0.875, + 0.5, + 1.083, + 2.167, + 1.125, + 1.958, + 0.625, + 0.459, + 0.416, + 2.584, + 1.416, + 0.625, + 1.042, + 1.833, + 6.125, + 1.959, + 9.291, + 2.834, + 0.666, + 0.5, + 2.792, + 1.875, + 1.583, + 4.25, + 1.875, + 0.625, + 0.792, + 0.75, + 1.125, + 0.75, + 0.75, + 0.667, + 2.416, + 5.417, + 3.025 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:37 镜,平均 2.0s/镜,快节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_ddc00649-36fc-4b8d-9b62-d4f743d9596f", + "source": "global_sample", + "durationSec": 74.025, + "sourceAspect": "1280:720", + "targetCanvasAspect": "9:16", + "layoutPreset": "letterbox_frame", + "frameStyle": { + "backgroundColor": "#050505", + "matte": true, + "roundedMask": false, + "labelStyle": "film_code", + "viewport": { + "aspectRatio": "1280:720", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "medium", + "hasMaskReveals": true, + "hasViewportSlides": true, + "preferredMotionPreset": "reveal_pan", + "preferredTransitionPreset": "whip_cut", + "notes": [ + "低阈值画面变化 69 个,硬切 36 个。", + "真实音频 onset 21 个。", + "样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。" + ] + }, + "audioOnsets": [ + { + "timeSec": 9.5, + "relativeTime": 0.128, + "strength": "weak", + "energyDb": -11.303 + }, + { + "timeSec": 12, + "relativeTime": 0.162, + "strength": "strong", + "energyDb": -9.978 + }, + { + "timeSec": 13.5, + "relativeTime": 0.182, + "strength": "medium", + "energyDb": -10.997 + }, + { + "timeSec": 17.5, + "relativeTime": 0.236, + "strength": "medium", + "energyDb": -10.956 + }, + { + "timeSec": 25.5, + "relativeTime": 0.344, + "strength": "weak", + "energyDb": -11.387 + }, + { + "timeSec": 29, + "relativeTime": 0.392, + "strength": "weak", + "energyDb": -11.374 + }, + { + "timeSec": 30.5, + "relativeTime": 0.412, + "strength": "weak", + "energyDb": -11.138 + }, + { + "timeSec": 31.5, + "relativeTime": 0.426, + "strength": "strong", + "energyDb": -9.422 + }, + { + "timeSec": 34.5, + "relativeTime": 0.466, + "strength": "strong", + "energyDb": -9.451 + }, + { + "timeSec": 36, + "relativeTime": 0.486, + "strength": "strong", + "energyDb": -9.976 + }, + { + "timeSec": 40.5, + "relativeTime": 0.547, + "strength": "strong", + "energyDb": -10.245 + }, + { + "timeSec": 41.5, + "relativeTime": 0.561, + "strength": "medium", + "energyDb": -10.341 + }, + { + "timeSec": 42.5, + "relativeTime": 0.574, + "strength": "weak", + "energyDb": -11.035 + }, + { + "timeSec": 44.5, + "relativeTime": 0.601, + "strength": "medium", + "energyDb": -10.535 + }, + { + "timeSec": 47, + "relativeTime": 0.635, + "strength": "strong", + "energyDb": -8.891 + }, + { + "timeSec": 49.5, + "relativeTime": 0.669, + "strength": "strong", + "energyDb": -10.308 + }, + { + "timeSec": 51.5, + "relativeTime": 0.696, + "strength": "strong", + "energyDb": -10.106 + }, + { + "timeSec": 58, + "relativeTime": 0.784, + "strength": "strong", + "energyDb": -10.085 + }, + { + "timeSec": 60.5, + "relativeTime": 0.817, + "strength": "strong", + "energyDb": -10.074 + }, + { + "timeSec": 63, + "relativeTime": 0.851, + "strength": "strong", + "energyDb": -10 + }, + { + "timeSec": 65.5, + "relativeTime": 0.885, + "strength": "strong", + "energyDb": -9.96 + } + ], + "events": [ + { + "kind": "mask_reveal", + "timeSec": 1.417, + "relativeTime": 0.019, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.208, + "relativeTime": 0.03, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 2.625, + "relativeTime": 0.035, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 6.208, + "relativeTime": 0.084, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 6.917, + "relativeTime": 0.093, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 7.625, + "relativeTime": 0.103, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 9, + "relativeTime": 0.122, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 9.333, + "relativeTime": 0.126, + "strength": "weak", + "direction": "left", + "nearestOnsetSec": 9.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 9.5, + "relativeTime": 0.128, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 10.083, + "relativeTime": 0.136, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 11.417, + "relativeTime": 0.154, + "strength": "strong", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 12, + "relativeTime": 0.162, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 13.333, + "relativeTime": 0.18, + "strength": "weak", + "direction": "left", + "nearestOnsetSec": 13.5, + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 13.5, + "relativeTime": 0.182, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "mask_reveal", + "timeSec": 13.792, + "relativeTime": 0.186, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 14.167, + "relativeTime": 0.191, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 14.5, + "relativeTime": 0.196, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 15.292, + "relativeTime": 0.207, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "mask_reveal", + "timeSec": 16.792, + "relativeTime": 0.227, + "strength": "weak", + "direction": "left", + "description": "低阈值画面变化,推断为模板内遮罩/滑动画面变化" + }, + { + "kind": "audio_onset", + "timeSec": 17.5, + "relativeTime": 0.236, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 25.5, + "relativeTime": 0.344, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 29, + "relativeTime": 0.392, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 30.5, + "relativeTime": 0.412, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 31.5, + "relativeTime": 0.426, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 34.5, + "relativeTime": 0.466, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 36, + "relativeTime": 0.486, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 40.5, + "relativeTime": 0.547, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 41.5, + "relativeTime": 0.561, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。", + "renderHints": [ + "letterbox_frame", + "reveal_pan", + "whip_cut", + "single_viewport", + "mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "核心主题开门见山 -> 高能效果初步预览 -> 多风格高能案例混剪 -> 顶级特效亮点强化 -> 账号引流引导", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "核心主题开门见山", + "durationRatio": 0.1, + "intent": "第一时间点明剪辑类教程主题,精准抓取目标受众注意力", + "copyPattern": "直给式抛出核心创作主题,无冗余铺垫", + "watchingPurpose": "开场抓停:第一时间点明剪辑类教程主题,精准抓取目标受众注意力" + }, + { + "role": "setup", + "label": "高能效果初步预览", + "durationRatio": 0.1, + "intent": "展示首个高辨识度高能画面,建立观众对最终产出效果的初步期待", + "copyPattern": "单样片快速露出,锚定内容风格调性", + "watchingPurpose": "建立背景:展示首个高辨识度高能画面,建立观众对最终产出效果的初步期待" + }, + { + "role": "develop", + "label": "多风格高能案例混剪", + "durationRatio": 0.6, + "intent": "批量覆盖实拍、动画、特效等不同品类的高能剪辑成片片段,直观呈现教程可实现的效果多样性", + "copyPattern": "高密度卡点混剪多维度参考案例,用视觉冲击力持续留住观众", + "watchingPurpose": "推进主体:批量覆盖实拍、动画、特效等不同品类的高能剪辑成片片段,直观呈现教程可实现的效果多样性" + }, + { + "role": "climax", + "label": "顶级特效亮点强化", + "durationRatio": 0.15, + "intent": "集中输出最具视觉冲击力的炸裂特效画面,最大化强化观众对教程价值的感知", + "copyPattern": "高冲击力特效片段集中输出,搭配情绪引导slogan放大爽感", + "watchingPurpose": "放大重点:集中输出最具视觉冲击力的炸裂特效画面,最大化强化观众对教程价值的感知" + }, + { + "role": "closing", + "label": "账号引流引导", + "durationRatio": 0.05, + "intent": "清晰告知观众获取完整教程的路径,完成私域/账号引流转化", + "copyPattern": "明确展示账号ID+搜索路径,降低用户行动门槛", + "watchingPurpose": "收束记忆点:清晰告知观众获取完整教程的路径,完成私域/账号引流转化" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 74.025, + "shotCount": 37, + "avgShotSec": 2, + "cutDensity": "high", + "peakAt": 0.7, + "beatHints": [ + "快节奏鼓点卡点匹配镜头切换", + "特效音同步画面飞溅/炸开动作", + "收尾平缓提示音配合引流页面展示" + ], + "rhythmNotes": [ + "平均 2.0s/镜,整体为 快节奏。", + "高潮位置约在全片 70%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「全屏大字体无遮挡醒目主题字」协同", + "animation": "字幕/标题可能配合「闪切/快切为主的卡点转场,匹配BGM鼓点节奏」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "全屏大字体无遮挡醒目主题字", + "stickerUsage": "仅右下角固定放置创作者账号水印贴纸,不遮挡核心画面", + "coverStyle": "黑底白字核心主题+最高能特效帧拼接的高对比度封面", + "overlayStyle": "全屏大字体无遮挡醒目主题字 / 仅右下角固定放置创作者账号水印贴纸,不遮挡核心画面", + "notes": [ + "画面包装迁移重点:全屏大字体无遮挡醒目主题字;仅右下角固定放置创作者账号水印贴纸,不遮挡核心画面;黑底白字核心主题+最高能特效帧拼接的高对比度封面", + "模板画幅:letterbox_frame,viewport=1280:720,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "闪切/快切为主的卡点转场,匹配BGM鼓点节奏", + "frequency": "高频切换", + "notableTransitions": [ + "闪切/快切为主的卡点转场,匹配BGM鼓点节奏" + ], + "executableTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=high 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "snap_cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_beat_pulse", + "name": "图片素材运镜:beat_pulse", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "beat_pulse", + "implementationNotes": "已映射到 MotionPreset=beat_pulse,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "快节奏鼓点卡点匹配镜头切换", + "特效音同步画面飞溅/炸开动作", + "收尾平缓提示音配合引流页面展示" + ], + "syncStrategy": "参考蓝图中的 3 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "slot_001", + "segmentRole": "hook", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + }, + { + "slotId": "slot_002", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll", + "usage_demo" + ], + "minDurationSec": 40, + "optional": false + }, + { + "slotId": "slot_003", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 74.025s", + "ref": "seed:v0d00fg10000d12foenog65g3j49gbe0.MP4" + }, + { + "type": "resolution", + "detail": "1280x720 @ 24fps" + }, + { + "type": "scene_cut", + "detail": "37 个镜头 / 36 个切点(原始 42 个,已合并 <0.4s 密集检测)", + "ref": "3.00, 7.25, 8.13, 8.63, 9.71, 11.88, 13.00, 14.96, 15.58, 16.04, 16.46, 19.04, 20.46, 21.08, 22.13, 23.96, 30.08, 32.04, 41.33, 44.17, 44.83, 45.33, 48.13, 50.00, 51.58, 55.83, 57.71, 58.33, 59.13, 59.88, 61.00, 61.75, 62.50, 63.17, 65.58, 71.00" + }, + { + "type": "template_profile", + "detail": "letterbox_frame;内部运动 medium;模板事件 28 个;音频 onset 21 个", + "ref": "letterbox_frame, reveal_pan, whip_cut, single_viewport, mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构先通过直给式主题钩子快速筛选剪辑爱好者类目标受众,避免无关用户流失;中段高密度多风格混剪用持续的视觉爽感不断拉高观众对教程内容的期待值,最大化激发学习欲望;高潮部分集中输出顶级特效片段强化内容价值感知,最后用低门槛的清晰引流引导完成用户留存,全程快节奏卡点完全适配短视频用户的碎片化观看习惯,无冗余信息消耗观众注意力。", + "createdAt": "2026-06-08T09:51:35.254Z", + "updatedAt": "2026-06-08T10:17:05.619Z" + }, + { + "id": "pattern_bdb1f423", + "scope": "global", + "sourceSampleId": "94cb93b7-5169-45d4-916e-28e364c557d8", + "name": "v0d00fg10000d38oqfnog65j7932m9ng · Vlog模式", + "summary": "该样例是 34s 的 Vlog 视频,结构为 治愈感惊喜开场 -> 日常碎片引入 -> 多场景日常铺陈 -> 氛围感情绪高点 -> 松弛感收尾定格,节奏为 快节奏。", + "videoGenre": "vlog", + "tags": [ + "vlog", + "high", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "full_story", + "ctaType": "generic_next_action", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导", + "包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "commercialUsefulness": "medium", + "recommendedUse": "secondary_story", + "warnings": [ + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导;包含产品、动作细节或结果证明类画面,不应用参考画面伪造" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "Vlog:治愈感惊喜开场 -> 松弛感收尾定格", + "formula": "治愈感惊喜开场 -> 日常碎片引入 -> 多场景日常铺陈 -> 氛围感情绪高点 -> 松弛感收尾定格", + "source": { + "filename": "v0d00fg10000d38oqfnog65j7932m9ng.MP4", + "durationSec": 34.016, + "aspectRatio": "720:960", + "shotCount": 32 + }, + "segments": [ + { + "role": "hook", + "label": "治愈感惊喜开场", + "durationRatio": 0.15, + "intent": "用超出日常惯性的趣味画面瞬间留住刷到的观众", + "copyPattern": "反差萌碎片开场,快速抛出两个有记忆点的特殊日常画面", + "watchingPurpose": "开场抓停:用超出日常惯性的趣味画面瞬间留住刷到的观众" + }, + { + "role": "setup", + "label": "日常碎片引入", + "durationRatio": 0.1, + "intent": "自然带出随手记录的生活化属性,降低观众心理预期门槛", + "copyPattern": "无旁白纯画面流铺垫,用零散随手拍画面传递随性记录的调性", + "watchingPurpose": "建立背景:自然带出随手记录的生活化属性,降低观众心理预期门槛" + }, + { + "role": "develop", + "label": "多场景日常铺陈", + "durationRatio": 0.55, + "intent": "按时间线排布不同的细碎生活片段,逐步积累松弛治愈的情绪", + "copyPattern": "无叙事逻辑的碎片化画面拼接,覆盖居家、通勤、户外、逛店多类场景", + "watchingPurpose": "推进主体:按时间线排布不同的细碎生活片段,逐步积累松弛治愈的情绪" + }, + { + "role": "climax", + "label": "氛围感情绪高点", + "durationRatio": 0.15, + "intent": "用黄昏暖调的高质感画面把松弛情绪推到峰值", + "copyPattern": "高饱和度暖光画面连续输出,强化治愈氛围感", + "watchingPurpose": "放大重点:用黄昏暖调的高质感画面把松弛情绪推到峰值" + }, + { + "role": "closing", + "label": "松弛感收尾定格", + "durationRatio": 0.05, + "intent": "用第一视角的生活化画面给观众留下代入感记忆点", + "copyPattern": "第一视角手持饮品的慢放定格,收束全片情绪", + "watchingPurpose": "收束记忆点:用第一视角的生活化画面给观众留下代入感记忆点" + } + ], + "pacing": { + "durationSec": 34.016, + "shotCount": 32, + "avgShotSec": 1.06, + "cutDensity": "high", + "peakAt": 0.7, + "beatHints": [ + "轻缓日系lofi背景音", + "每一次镜头切换精准踩BGM节拍落点" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "底部固定细条文艺标识水印,无顶部大标题", + "stickerUsage": "低占比萌系手绘特效,仅点缀云朵、路人头顶等局部画面", + "transitionStyle": "全片无额外转场特效,全部用快切匹配BGM节拍", + "coverStyle": "选取开篇低空客机+萌化云朵的高记忆点帧作为封面,无多余文字叠加" + }, + "storySkeleton": { + "arcType": "Vlog / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "用超出日常惯性的趣味画面瞬间留住刷到的观众", + "自然带出随手记录的生活化属性,降低观众心理预期门槛", + "按时间线排布不同的细碎生活片段,逐步积累松弛治愈的情绪", + "用黄昏暖调的高质感画面把松弛情绪推到峰值", + "用第一视角的生活化画面给观众留下代入感记忆点" + ], + "hookStyle": "反差萌碎片开场,快速抛出两个有记忆点的特殊日常画面", + "turnOrProofStyle": "无叙事逻辑的碎片化画面拼接,覆盖居家、通勤、户外、逛店多类场景", + "payoffStyle": "第一视角手持饮品的慢放定格,收束全片情绪", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "vlog" + ], + "assetRequirements": [ + "b_roll", + "usage_demo" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=high 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "snap_cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "high", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_beat_pulse", + "name": "图片素材运镜:beat_pulse", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "beat_pulse", + "beatPlacement": "on estimated beat grid", + "intensity": "high", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=beat_pulse,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ], + "packagingPattern": { + "titleBarStyle": "底部固定细条文艺标识水印,无顶部大标题", + "stickerUsage": "低占比萌系手绘特效,仅点缀云朵、路人头顶等局部画面", + "coverStyle": "选取开篇低空客机+萌化云朵的高记忆点帧作为封面,无多余文字叠加", + "overlayStyle": "底部固定细条文艺标识水印,无顶部大标题 / 低占比萌系手绘特效,仅点缀云朵、路人头顶等局部画面", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "visual cuts align to dense beat grid", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 34.016, + "bpm": 128, + "beatCount": 73, + "beatStability": 0.6821655132641291, + "onsetDensity": 0.9407337723424272, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625, + 7.5, + 9.375, + 11.25, + 13.125, + 15, + 16.875, + 18.75, + 20.625, + 22.5, + 24.375, + 26.25, + 28.125, + 30, + 31.875, + 33.75 + ], + "phraseBoundariesSec": [ + 0, + 3.75, + 7.5, + 11.25, + 15, + 18.75, + 22.5, + 26.25, + 30, + 33.75 + ], + "sections": [ + { + "startSec": 0, + "endSec": 6.123, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625 + ] + }, + { + "startSec": 6.123, + "endSec": 18.709, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 7.5, + 9.375, + 11.25, + 13.125, + 15, + 16.875 + ] + }, + { + "startSec": 18.709, + "endSec": 24.832, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 18.75, + 20.625, + 22.5, + 24.375 + ] + }, + { + "startSec": 24.832, + "endSec": 34.016, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 26.25, + 28.125, + 30, + 31.875, + 33.75 + ] + } + ], + "tags": [ + "vlog", + "density:high", + "bpm:128", + "shape:rising" + ] + }, + "rhythmProfile": { + "id": "rhythm_94cb93b7-5169-45d4-916e-28e364c557d8", + "source": "global_sample", + "durationSec": 34.016, + "music": { + "hasAudio": true, + "durationSec": 34.016, + "bpm": 128, + "beatCount": 73, + "beatStability": 0.6821655132641291, + "onsetDensity": 0.9407337723424272, + "energyShape": "rising", + "peakAt": 0.7, + "confidence": "medium", + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625, + 7.5, + 9.375, + 11.25, + 13.125, + 15, + 16.875, + 18.75, + 20.625, + 22.5, + 24.375, + 26.25, + 28.125, + 30, + 31.875, + 33.75 + ], + "phraseBoundariesSec": [ + 0, + 3.75, + 7.5, + 11.25, + 15, + 18.75, + 22.5, + 26.25, + 30, + 33.75 + ], + "sections": [ + { + "startSec": 0, + "endSec": 6.123, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 1.875, + 3.75, + 5.625 + ] + }, + { + "startSec": 6.123, + "endSec": 18.709, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 7.5, + 9.375, + 11.25, + 13.125, + 15, + 16.875 + ] + }, + { + "startSec": 18.709, + "endSec": 24.832, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 18.75, + 20.625, + 22.5, + 24.375 + ] + }, + { + "startSec": 24.832, + "endSec": 34.016, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 26.25, + 28.125, + 30, + 31.875, + 33.75 + ] + } + ], + "tags": [ + "vlog", + "density:high", + "bpm:128", + "shape:rising" + ] + }, + "shotPattern": { + "cutDensity": "high", + "shotCount": 32, + "avgShotSec": 1.06, + "peakAt": 0.7, + "cutEveryBeats": 1.85, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "治愈感惊喜开场" + }, + { + "eventType": "cut", + "timeSec": 0.483, + "relativeTime": 0.014199200376293509, + "beatIndex": 1, + "phraseIndex": 0, + "nearestBeatSec": 0.469, + "offsetMs": 14, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 1.717, + "relativeTime": 0.05047624647224836, + "beatIndex": 4, + "phraseIndex": 0, + "nearestBeatSec": 1.875, + "offsetMs": -158, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 2.533, + "relativeTime": 0.07446495766698025, + "beatIndex": 5, + "phraseIndex": 0, + "nearestBeatSec": 2.344, + "offsetMs": 189, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 3.367, + "relativeTime": 0.09898283160865476, + "beatIndex": 7, + "phraseIndex": 0, + "nearestBeatSec": 3.281, + "offsetMs": 86, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 4.317, + "relativeTime": 0.12691086547507058, + "beatIndex": 9, + "phraseIndex": 1, + "nearestBeatSec": 4.219, + "offsetMs": 98, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 5.102, + "relativeTime": 0.14998824082784573, + "beatIndex": 11, + "phraseIndex": 1, + "nearestBeatSec": 5.156, + "offsetMs": -54, + "segmentRole": "setup", + "strength": "medium", + "description": "日常碎片引入" + }, + { + "eventType": "cut", + "timeSec": 5.2, + "relativeTime": 0.15286923800564442, + "beatIndex": 11, + "phraseIndex": 1, + "nearestBeatSec": 5.156, + "offsetMs": 44, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 6, + "relativeTime": 0.1763875823142051, + "beatIndex": 13, + "phraseIndex": 1, + "nearestBeatSec": 6.094, + "offsetMs": -94, + "strength": "medium", + "description": "样例第 7 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 6.883, + "relativeTime": 0.20234595484477894, + "beatIndex": 15, + "phraseIndex": 1, + "nearestBeatSec": 7.031, + "offsetMs": -148, + "strength": "medium", + "description": "样例第 8 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 8.504, + "relativeTime": 0.25, + "beatIndex": 18, + "phraseIndex": 2, + "nearestBeatSec": 8.438, + "offsetMs": 66, + "segmentRole": "develop", + "strength": "medium", + "description": "多场景日常铺陈" + }, + { + "eventType": "cut", + "timeSec": 10.383, + "relativeTime": 0.3052387111947319, + "beatIndex": 22, + "phraseIndex": 2, + "nearestBeatSec": 10.313, + "offsetMs": 70, + "strength": "medium", + "description": "样例第 9 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 11.2, + "relativeTime": 0.3292568203198495, + "beatIndex": 24, + "phraseIndex": 3, + "nearestBeatSec": 11.25, + "offsetMs": -50, + "strength": "medium", + "description": "样例第 10 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 12.817, + "relativeTime": 0.3767932737535278, + "beatIndex": 27, + "phraseIndex": 3, + "nearestBeatSec": 12.656, + "offsetMs": 161, + "strength": "medium", + "description": "样例第 11 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 13.717, + "relativeTime": 0.40325141110065854, + "beatIndex": 29, + "phraseIndex": 3, + "nearestBeatSec": 13.594, + "offsetMs": 123, + "strength": "medium", + "description": "样例第 12 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 14.5, + "relativeTime": 0.4262699905926623, + "beatIndex": 31, + "phraseIndex": 3, + "nearestBeatSec": 14.531, + "offsetMs": -31, + "strength": "medium", + "description": "样例第 13 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 15.367, + "relativeTime": 0.451757996237065, + "beatIndex": 33, + "phraseIndex": 4, + "nearestBeatSec": 15.469, + "offsetMs": -102, + "strength": "medium", + "description": "样例第 14 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 16.25, + "relativeTime": 0.4777163687676388, + "beatIndex": 35, + "phraseIndex": 4, + "nearestBeatSec": 16.406, + "offsetMs": -156, + "strength": "medium", + "description": "样例第 15 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 17.1, + "relativeTime": 0.5027046095954846, + "beatIndex": 36, + "phraseIndex": 4, + "nearestBeatSec": 16.875, + "offsetMs": 225, + "strength": "medium", + "description": "样例第 16 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 17.8, + "relativeTime": 0.5232831608654751, + "beatIndex": 38, + "phraseIndex": 4, + "nearestBeatSec": 17.813, + "offsetMs": -13, + "strength": "medium", + "description": "样例第 17 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 18.667, + "relativeTime": 0.5487711665098778, + "beatIndex": 40, + "phraseIndex": 5, + "nearestBeatSec": 18.75, + "offsetMs": -83, + "strength": "medium", + "description": "样例第 18 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 19.55, + "relativeTime": 0.5747295390404517, + "beatIndex": 42, + "phraseIndex": 5, + "nearestBeatSec": 19.688, + "offsetMs": -138, + "strength": "medium", + "description": "样例第 19 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 20.55, + "relativeTime": 0.6041274694261525, + "beatIndex": 44, + "phraseIndex": 5, + "nearestBeatSec": 20.625, + "offsetMs": -75, + "strength": "medium", + "description": "样例第 20 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 21.367, + "relativeTime": 0.6281455785512701, + "beatIndex": 46, + "phraseIndex": 5, + "nearestBeatSec": 21.563, + "offsetMs": -196, + "strength": "medium", + "description": "样例第 21 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 22.117, + "relativeTime": 0.6501940263405457, + "beatIndex": 47, + "phraseIndex": 5, + "nearestBeatSec": 22.031, + "offsetMs": 86, + "strength": "medium", + "description": "样例第 22 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 23.017, + "relativeTime": 0.6766521636876764, + "beatIndex": 49, + "phraseIndex": 6, + "nearestBeatSec": 22.969, + "offsetMs": 48, + "strength": "medium", + "description": "样例第 23 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 24.033, + "relativeTime": 0.7065204609595486, + "beatIndex": 51, + "phraseIndex": 6, + "nearestBeatSec": 23.906, + "offsetMs": 127, + "strength": "medium", + "description": "样例第 24 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 24.767, + "relativeTime": 0.7280985418626529, + "beatIndex": 53, + "phraseIndex": 6, + "nearestBeatSec": 24.844, + "offsetMs": -77, + "strength": "medium", + "description": "样例第 25 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 25.617, + "relativeTime": 0.7530867826904987, + "beatIndex": 55, + "phraseIndex": 6, + "nearestBeatSec": 25.781, + "offsetMs": -164, + "strength": "medium", + "description": "样例第 26 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 26.483, + "relativeTime": 0.7785453904045155, + "beatIndex": 56, + "phraseIndex": 7, + "nearestBeatSec": 26.25, + "offsetMs": 233, + "strength": "medium", + "description": "样例第 27 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 27.213, + "relativeTime": 0.8000058795860772, + "beatIndex": 58, + "phraseIndex": 7, + "nearestBeatSec": 27.188, + "offsetMs": 25, + "segmentRole": "climax", + "strength": "medium", + "description": "氛围感情绪高点" + }, + { + "eventType": "cut", + "timeSec": 27.767, + "relativeTime": 0.8162923330197555, + "beatIndex": 59, + "phraseIndex": 7, + "nearestBeatSec": 27.656, + "offsetMs": 111, + "strength": "medium", + "description": "样例第 28 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 28.533, + "relativeTime": 0.8388111476952024, + "beatIndex": 61, + "phraseIndex": 7, + "nearestBeatSec": 28.594, + "offsetMs": -61, + "strength": "medium", + "description": "样例第 29 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 29.417, + "relativeTime": 0.8647989181561619, + "beatIndex": 63, + "phraseIndex": 7, + "nearestBeatSec": 29.531, + "offsetMs": -114, + "strength": "medium", + "description": "样例第 30 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 30.283, + "relativeTime": 0.8902575258701788, + "beatIndex": 65, + "phraseIndex": 8, + "nearestBeatSec": 30.469, + "offsetMs": -186, + "strength": "strong", + "description": "样例第 31 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 32.315, + "relativeTime": 0.9499941204139228, + "beatIndex": 69, + "phraseIndex": 8, + "nearestBeatSec": 32.344, + "offsetMs": -29, + "segmentRole": "closing", + "strength": "medium", + "description": "松弛感收尾定格" + } + ], + "cutIntervalsSec": [ + 0.483, + 1.234, + 0.816, + 0.834, + 0.95, + 0.883, + 0.8, + 0.883, + 3.5, + 0.817, + 1.617, + 0.9, + 0.783, + 0.867, + 0.883, + 0.85, + 0.7, + 0.867, + 0.883, + 1, + 0.817, + 0.75, + 0.9, + 1.016, + 0.734, + 0.85, + 0.866, + 1.284, + 0.766, + 0.884, + 0.866, + 3.733 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:32 镜,平均 1.1s/镜,快节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_94cb93b7-5169-45d4-916e-28e364c557d8", + "source": "global_sample", + "durationSec": 34.016, + "sourceAspect": "720:960", + "targetCanvasAspect": "9:16", + "layoutPreset": "full_bleed", + "frameStyle": { + "backgroundColor": "#050505", + "matte": false, + "roundedMask": false, + "labelStyle": "none", + "viewport": { + "aspectRatio": "720:960", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": false, + "hasViewportSlides": false, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 6 个,硬切 31 个。", + "真实音频 onset 10 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 8, + "relativeTime": 0.235, + "strength": "strong", + "energyDb": -10.549 + }, + { + "timeSec": 9, + "relativeTime": 0.265, + "strength": "strong", + "energyDb": -11.86 + }, + { + "timeSec": 10, + "relativeTime": 0.294, + "strength": "strong", + "energyDb": -12.381 + }, + { + "timeSec": 13, + "relativeTime": 0.382, + "strength": "strong", + "energyDb": -11.548 + }, + { + "timeSec": 14.5, + "relativeTime": 0.426, + "strength": "weak", + "energyDb": -13.248 + }, + { + "timeSec": 20, + "relativeTime": 0.588, + "strength": "medium", + "energyDb": -12.764 + }, + { + "timeSec": 23.5, + "relativeTime": 0.691, + "strength": "strong", + "energyDb": -11.287 + }, + { + "timeSec": 25, + "relativeTime": 0.735, + "strength": "medium", + "energyDb": -12.803 + }, + { + "timeSec": 26.5, + "relativeTime": 0.779, + "strength": "strong", + "energyDb": -11.094 + }, + { + "timeSec": 29.5, + "relativeTime": 0.867, + "strength": "medium", + "energyDb": -12.947 + } + ], + "events": [ + { + "kind": "internal_motion", + "timeSec": 0.883, + "relativeTime": 0.026, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 7.733, + "relativeTime": 0.227, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 8, + "relativeTime": 0.235, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 8.567, + "relativeTime": 0.252, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 9, + "relativeTime": 0.265, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 9.4, + "relativeTime": 0.276, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 10, + "relativeTime": 0.294, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 12, + "relativeTime": 0.353, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 13, + "relativeTime": 0.382, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 14.5, + "relativeTime": 0.426, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 18.35, + "relativeTime": 0.539, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 20, + "relativeTime": 0.588, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 23.5, + "relativeTime": 0.691, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 25, + "relativeTime": 0.735, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 26.5, + "relativeTime": 0.779, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 29.5, + "relativeTime": 0.867, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。", + "renderHints": [ + "full_bleed", + "ken_burns_in", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "治愈感惊喜开场 -> 日常碎片引入 -> 多场景日常铺陈 -> 氛围感情绪高点 -> 松弛感收尾定格", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "治愈感惊喜开场", + "durationRatio": 0.15, + "intent": "用超出日常惯性的趣味画面瞬间留住刷到的观众", + "copyPattern": "反差萌碎片开场,快速抛出两个有记忆点的特殊日常画面", + "watchingPurpose": "开场抓停:用超出日常惯性的趣味画面瞬间留住刷到的观众" + }, + { + "role": "setup", + "label": "日常碎片引入", + "durationRatio": 0.1, + "intent": "自然带出随手记录的生活化属性,降低观众心理预期门槛", + "copyPattern": "无旁白纯画面流铺垫,用零散随手拍画面传递随性记录的调性", + "watchingPurpose": "建立背景:自然带出随手记录的生活化属性,降低观众心理预期门槛" + }, + { + "role": "develop", + "label": "多场景日常铺陈", + "durationRatio": 0.55, + "intent": "按时间线排布不同的细碎生活片段,逐步积累松弛治愈的情绪", + "copyPattern": "无叙事逻辑的碎片化画面拼接,覆盖居家、通勤、户外、逛店多类场景", + "watchingPurpose": "推进主体:按时间线排布不同的细碎生活片段,逐步积累松弛治愈的情绪" + }, + { + "role": "climax", + "label": "氛围感情绪高点", + "durationRatio": 0.15, + "intent": "用黄昏暖调的高质感画面把松弛情绪推到峰值", + "copyPattern": "高饱和度暖光画面连续输出,强化治愈氛围感", + "watchingPurpose": "放大重点:用黄昏暖调的高质感画面把松弛情绪推到峰值" + }, + { + "role": "closing", + "label": "松弛感收尾定格", + "durationRatio": 0.05, + "intent": "用第一视角的生活化画面给观众留下代入感记忆点", + "copyPattern": "第一视角手持饮品的慢放定格,收束全片情绪", + "watchingPurpose": "收束记忆点:用第一视角的生活化画面给观众留下代入感记忆点" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 34.016, + "shotCount": 32, + "avgShotSec": 1.06, + "cutDensity": "high", + "peakAt": 0.7, + "beatHints": [ + "轻缓日系lofi背景音", + "每一次镜头切换精准踩BGM节拍落点" + ], + "rhythmNotes": [ + "平均 1.1s/镜,整体为 快节奏。", + "高潮位置约在全片 70%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「底部固定细条文艺标识水印,无顶部大标题」协同", + "animation": "字幕/标题可能配合「全片无额外转场特效,全部用快切匹配BGM节拍」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "底部固定细条文艺标识水印,无顶部大标题", + "stickerUsage": "低占比萌系手绘特效,仅点缀云朵、路人头顶等局部画面", + "coverStyle": "选取开篇低空客机+萌化云朵的高记忆点帧作为封面,无多余文字叠加", + "overlayStyle": "底部固定细条文艺标识水印,无顶部大标题 / 低占比萌系手绘特效,仅点缀云朵、路人头顶等局部画面", + "notes": [ + "画面包装迁移重点:底部固定细条文艺标识水印,无顶部大标题;低占比萌系手绘特效,仅点缀云朵、路人头顶等局部画面;选取开篇低空客机+萌化云朵的高记忆点帧作为封面,无多余文字叠加", + "模板画幅:full_bleed,viewport=720:960,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "全片无额外转场特效,全部用快切匹配BGM节拍", + "frequency": "高频切换", + "notableTransitions": [ + "全片无额外转场特效,全部用快切匹配BGM节拍" + ], + "executableTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=high 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "snap_cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_beat_pulse", + "name": "图片素材运镜:beat_pulse", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "beat_pulse", + "implementationNotes": "已映射到 MotionPreset=beat_pulse,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "轻缓日系lofi背景音", + "每一次镜头切换精准踩BGM节拍落点" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "S1", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 5, + "optional": false + }, + { + "slotId": "S2", + "segmentRole": "develop", + "requiredAssetTypes": [ + "b_roll", + "usage_demo" + ], + "minDurationSec": 18, + "optional": false + }, + { + "slotId": "S3", + "segmentRole": "climax", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 5, + "optional": false + }, + { + "slotId": "S4", + "segmentRole": "closing", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 2, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 34.016s", + "ref": "seed:v0d00fg10000d38oqfnog65j7932m9ng.MP4" + }, + { + "type": "resolution", + "detail": "720x960 @ 60fps" + }, + { + "type": "scene_cut", + "detail": "32 个镜头 / 31 个切点(原始 31 个,已合并 <0.4s 密集检测)", + "ref": "0.48, 1.72, 2.53, 3.37, 4.32, 5.20, 6.00, 6.88, 10.38, 11.20, 12.82, 13.72, 14.50, 15.37, 16.25, 17.10, 17.80, 18.67, 19.55, 20.55, 21.37, 22.12, 23.02, 24.03, 24.77, 25.62, 26.48, 27.77, 28.53, 29.42, 30.28" + }, + { + "type": "template_profile", + "detail": "full_bleed;内部运动 low;模板事件 16 个;音频 onset 10 个", + "ref": "full_bleed, ken_burns_in, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 12 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构开篇用极具新鲜感的低空客机+萌化云朵画面快速抓住刷流用户的注意力,全程高频率快切匹配轻缓BGM,把零散日常碎片按从白天到黄昏的时间线排布,逐步积累松弛治愈的情绪,最终在黄昏暖调街景处推到情绪高点,最后用第一视角手持饮品的画面收尾,全程无冗余信息,让观众快速感知到普通日常里的细碎美好,获得短时间的情绪放松体验。", + "createdAt": "2026-06-08T09:52:30.402Z", + "updatedAt": "2026-06-08T10:17:07.443Z" + }, + { + "id": "pattern_24d6b9ca", + "scope": "global", + "sourceSampleId": "994397b4-7a9e-4af6-befc-a9572b952dc8", + "name": "v2800fgi0000d6udia7og65lv60bsos0 · Vlog模式", + "summary": "该样例是 19s 的 Vlog 视频,结构为 氛围感开篇引入 -> 多场景情绪铺垫 -> 第一视角日常代入 -> 首尾呼应情绪点题 -> 平台引流引导,节奏为 中等节奏。", + "videoGenre": "vlog", + "tags": [ + "vlog", + "medium", + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "qualityTags": { + "patternDepth": "full_story", + "ctaType": "platform_follow", + "visualBridgePolicy": { + "use": "blocked", + "reasons": [ + "包含真人 / 人像 / 游客 / 口播风险画面", + "包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "commercialUsefulness": "weak", + "recommendedUse": "secondary_story", + "warnings": [ + "检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。", + "商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。", + "视觉桥接限制:包含真人 / 人像 / 游客 / 口播风险画面;包含平台水印 / 账号标识 / 搜索引导" + ] + }, + "learnScope": { + "storySkeleton": true, + "editingTechniques": true, + "packagingStyle": true, + "bgmSync": true + }, + "reusablePatternName": "Vlog:氛围感开篇引入 -> 平台引流引导", + "formula": "氛围感开篇引入 -> 多场景情绪铺垫 -> 第一视角日常代入 -> 首尾呼应情绪点题 -> 平台引流引导", + "source": { + "filename": "v2800fgi0000d6udia7og65lv60bsos0.MP4", + "durationSec": 19.133, + "aspectRatio": "720:1280", + "shotCount": 8 + }, + "segments": [ + { + "role": "hook", + "label": "氛围感开篇引入", + "durationRatio": 0.16, + "intent": "快速建立都市夜生活的情绪基调,第一时间抓住偏好氛围感内容的用户注意力", + "copyPattern": "标志性场景快切开篇,用视觉冲击替代冗余台词", + "watchingPurpose": "开场抓停:快速建立都市夜生活的情绪基调,第一时间抓住偏好氛围感内容的用户注意力" + }, + { + "role": "setup", + "label": "多场景情绪铺垫", + "durationRatio": 0.29, + "intent": "通过不同的治愈系夜间画面堆叠,逐步烘托松弛的氛围感", + "copyPattern": "无叙事性蒙太奇串联碎片化风景画面", + "watchingPurpose": "建立背景:通过不同的治愈系夜间画面堆叠,逐步烘托松弛的氛围感" + }, + { + "role": "develop", + "label": "第一视角日常代入", + "durationRatio": 0.28, + "intent": "切换为普通人的出行第一视角,强化观众的沉浸式代入感", + "copyPattern": "第一视角记录日常出行片段,弱化刻意叙事感", + "watchingPurpose": "推进主体:切换为普通人的出行第一视角,强化观众的沉浸式代入感" + }, + { + "role": "climax", + "label": "首尾呼应情绪点题", + "durationRatio": 0.1, + "intent": "回到开篇场景,用文字点出核心情绪,完成共鸣触发", + "copyPattern": "场景闭环呼应+关键词文字点题", + "watchingPurpose": "放大重点:回到开篇场景,用文字点出核心情绪,完成共鸣触发" + }, + { + "role": "closing", + "label": "平台引流引导", + "durationRatio": 0.17, + "intent": "清晰告知用户相关内容的搜索路径,完成引流转化", + "copyPattern": "官方功能页直接展示操作指引", + "watchingPurpose": "收束记忆点:清晰告知用户相关内容的搜索路径,完成引流转化" + } + ], + "pacing": { + "durationSec": 19.133, + "shotCount": 8, + "avgShotSec": 2.39, + "cutDensity": "medium", + "peakAt": 0.75, + "beatHints": [ + "慢节奏氛围感BGM对齐镜头切点", + "情绪点题时刻BGM音量小幅抬升" + ] + }, + "packaging": { + "subtitleDensity": "sparse", + "titleBarStyle": "无显性顶部标题条,仅画面角落保留账号标识", + "stickerUsage": "无额外装饰贴纸,仅叠加少量短句情绪文字", + "transitionStyle": "硬切为主,情绪衔接段使用淡入淡出效果,收尾跳转使用快切", + "coverStyle": "选取视觉冲击力最强的月夜竹林帧作为封面,突出氛围感属性" + }, + "storySkeleton": { + "arcType": "Vlog / hook -> setup -> develop -> climax -> closing", + "segmentRoles": [ + "hook", + "setup", + "develop", + "climax", + "closing" + ], + "emotionalCurve": [ + "快速建立都市夜生活的情绪基调,第一时间抓住偏好氛围感内容的用户注意力", + "通过不同的治愈系夜间画面堆叠,逐步烘托松弛的氛围感", + "切换为普通人的出行第一视角,强化观众的沉浸式代入感", + "回到开篇场景,用文字点出核心情绪,完成共鸣触发", + "清晰告知用户相关内容的搜索路径,完成引流转化" + ], + "hookStyle": "标志性场景快切开篇,用视觉冲击替代冗余台词", + "turnOrProofStyle": "第一视角记录日常出行片段,弱化刻意叙事感", + "payoffStyle": "官方功能页直接展示操作指引", + "requiredStoryFunctions": [ + "opening_hook", + "context", + "action", + "detail", + "turn", + "proof", + "payoff", + "cta" + ], + "bestForGenres": [ + "vlog" + ], + "assetRequirements": [ + "b_roll", + "talking_head", + "text_card" + ] + }, + "editingTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunction": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "appliesToAssetType": [ + "video", + "image" + ], + "appliesToVisualCluster": [], + "transitionPreset": "snap_cut", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "high", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunction": [ + "context", + "detail", + "payoff" + ], + "appliesToAssetType": [ + "image" + ], + "appliesToVisualCluster": [], + "motionPreset": "parallax_drift", + "beatPlacement": "phrase boundary or shot boundary", + "intensity": "medium", + "avoidWhen": [ + "dialogue_heavy", + "proof_requires_detail" + ], + "requiredRenderer": "both", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunction": [ + "opening_hook", + "transition", + "cta" + ], + "appliesToAssetType": [ + "text" + ], + "appliesToVisualCluster": [], + "beatPlacement": "phrase boundary or shot boundary", + "cardAnimationPreset": "soft_crossfade", + "intensity": "medium", + "avoidWhen": [ + "shot_requires_no_distraction" + ], + "requiredRenderer": "remotion", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ], + "packagingPattern": { + "titleBarStyle": "无显性顶部标题条,仅画面角落保留账号标识", + "stickerUsage": "无额外装饰贴纸,仅叠加少量短句情绪文字", + "coverStyle": "选取视觉冲击力最强的月夜竹林帧作为封面,突出氛围感属性", + "overlayStyle": "无显性顶部标题条,仅画面角落保留账号标识 / 无额外装饰贴纸,仅叠加少量短句情绪文字", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。" + }, + "bgmSyncPattern": { + "beatPlacement": "major transitions align to phrase beats", + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + }, + "musicFingerprint": { + "hasAudio": true, + "durationSec": 19.133, + "bpm": 112, + "beatCount": 36, + "beatStability": 0.6308928571428571, + "onsetDensity": 0.4181257513197094, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143 + ], + "sections": [ + { + "startSec": 0, + "endSec": 3.444, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143 + ] + }, + { + "startSec": 3.444, + "endSec": 13.01, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 4.286, + 6.429, + 8.571, + 10.714, + 12.857 + ] + }, + { + "startSec": 13.01, + "endSec": 16.454, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 15 + ] + }, + { + "startSec": 16.454, + "endSec": 19.133, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 17.143 + ] + } + ], + "tags": [ + "vlog", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "rhythmProfile": { + "id": "rhythm_994397b4-7a9e-4af6-befc-a9572b952dc8", + "source": "global_sample", + "durationSec": 19.133, + "music": { + "hasAudio": true, + "durationSec": 19.133, + "bpm": 112, + "beatCount": 36, + "beatStability": 0.6308928571428571, + "onsetDensity": 0.4181257513197094, + "energyShape": "late_peak", + "peakAt": 0.75, + "confidence": "medium", + "downbeatsSec": [ + 0, + 2.143, + 4.286, + 6.429, + 8.571, + 10.714, + 12.857, + 15, + 17.143 + ], + "phraseBoundariesSec": [ + 0, + 4.286, + 8.571, + 12.857, + 17.143 + ], + "sections": [ + { + "startSec": 0, + "endSec": 3.444, + "kind": "intro", + "confidence": 0.42, + "downbeatsSec": [ + 0, + 2.143 + ] + }, + { + "startSec": 3.444, + "endSec": 13.01, + "kind": "build", + "confidence": 0.38, + "downbeatsSec": [ + 4.286, + 6.429, + 8.571, + 10.714, + 12.857 + ] + }, + { + "startSec": 13.01, + "endSec": 16.454, + "kind": "drop", + "confidence": 0.38, + "downbeatsSec": [ + 15 + ] + }, + { + "startSec": 16.454, + "endSec": 19.133, + "kind": "outro", + "confidence": 0.35, + "downbeatsSec": [ + 17.143 + ] + } + ], + "tags": [ + "vlog", + "density:medium", + "bpm:112", + "shape:late_peak" + ] + }, + "shotPattern": { + "cutDensity": "medium", + "shotCount": 8, + "avgShotSec": 2.39, + "peakAt": 0.75, + "cutEveryBeats": 3.92, + "phraseLengthBeats": 8 + }, + "events": [ + { + "eventType": "title", + "timeSec": 0, + "relativeTime": 0, + "beatIndex": 0, + "phraseIndex": 0, + "nearestBeatSec": 0, + "offsetMs": 0, + "segmentRole": "hook", + "strength": "strong", + "description": "氛围感开篇引入" + }, + { + "eventType": "caption", + "timeSec": 3.061, + "relativeTime": 0.15998536559870383, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 3.214, + "offsetMs": -153, + "segmentRole": "setup", + "strength": "medium", + "description": "多场景情绪铺垫" + }, + { + "eventType": "cut", + "timeSec": 3.167, + "relativeTime": 0.16552553180368995, + "beatIndex": 6, + "phraseIndex": 0, + "nearestBeatSec": 3.214, + "offsetMs": -47, + "strength": "strong", + "description": "样例第 1 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 4.4, + "relativeTime": 0.2299691632258402, + "beatIndex": 8, + "phraseIndex": 1, + "nearestBeatSec": 4.286, + "offsetMs": 114, + "strength": "medium", + "description": "样例第 2 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 8.61, + "relativeTime": 0.4500078398578372, + "beatIndex": 16, + "phraseIndex": 2, + "nearestBeatSec": 8.571, + "offsetMs": 39, + "segmentRole": "develop", + "strength": "medium", + "description": "第一视角日常代入" + }, + { + "eventType": "cut", + "timeSec": 8.767, + "relativeTime": 0.4582135577274865, + "beatIndex": 16, + "phraseIndex": 2, + "nearestBeatSec": 8.571, + "offsetMs": 196, + "strength": "medium", + "description": "样例第 3 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 10.867, + "relativeTime": 0.5679715674489103, + "beatIndex": 20, + "phraseIndex": 2, + "nearestBeatSec": 10.714, + "offsetMs": 153, + "strength": "medium", + "description": "样例第 4 个切镜点" + }, + { + "eventType": "cut", + "timeSec": 12.767, + "relativeTime": 0.6672764333873412, + "beatIndex": 24, + "phraseIndex": 3, + "nearestBeatSec": 12.857, + "offsetMs": -90, + "strength": "medium", + "description": "样例第 5 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 13.967, + "relativeTime": 0.7299952960852977, + "beatIndex": 26, + "phraseIndex": 3, + "nearestBeatSec": 13.929, + "offsetMs": 38, + "segmentRole": "climax", + "strength": "medium", + "description": "首尾呼应情绪点题" + }, + { + "eventType": "cut", + "timeSec": 14.167, + "relativeTime": 0.7404484398682905, + "beatIndex": 26, + "phraseIndex": 3, + "nearestBeatSec": 13.929, + "offsetMs": 238, + "strength": "medium", + "description": "样例第 6 个切镜点" + }, + { + "eventType": "caption", + "timeSec": 15.88, + "relativeTime": 0.8299796163696233, + "beatIndex": 30, + "phraseIndex": 3, + "nearestBeatSec": 16.071, + "offsetMs": -191, + "segmentRole": "closing", + "strength": "medium", + "description": "平台引流引导" + }, + { + "eventType": "cut", + "timeSec": 16.1, + "relativeTime": 0.8414780745309153, + "beatIndex": 30, + "phraseIndex": 3, + "nearestBeatSec": 16.071, + "offsetMs": 29, + "strength": "strong", + "description": "样例第 7 个切镜点" + } + ], + "cutIntervalsSec": [ + 3.167, + 1.233, + 4.367, + 2.1, + 1.9, + 1.4, + 1.933, + 3.033 + ], + "captionStrategy": "只在 hook、重点和收尾处出现字幕。", + "strategySummary": "样例节奏:8 镜,平均 2.4s/镜,中等节奏;切镜事件按 beat index 存储,迁移时映射到目标 BGM。" + }, + "templateProfile": { + "id": "tpl_994397b4-7a9e-4af6-befc-a9572b952dc8", + "source": "global_sample", + "durationSec": 19.133, + "sourceAspect": "720:1280", + "targetCanvasAspect": "9:16", + "layoutPreset": "full_bleed", + "frameStyle": { + "backgroundColor": "#050505", + "matte": false, + "roundedMask": false, + "labelStyle": "none", + "viewport": { + "aspectRatio": "720:1280", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + } + }, + "motionLanguage": { + "internalMotionIntensity": "low", + "hasMaskReveals": false, + "hasViewportSlides": false, + "preferredMotionPreset": "ken_burns_in", + "preferredTransitionPreset": "cut", + "notes": [ + "低阈值画面变化 4 个,硬切 7 个。", + "真实音频 onset 5 个。", + "样例以内在运镜或普通画面变化为主。" + ] + }, + "audioOnsets": [ + { + "timeSec": 0.5, + "relativeTime": 0.026, + "strength": "medium", + "energyDb": -17.652 + }, + { + "timeSec": 4.5, + "relativeTime": 0.235, + "strength": "weak", + "energyDb": -18.569 + }, + { + "timeSec": 8.5, + "relativeTime": 0.444, + "strength": "strong", + "energyDb": -17.085 + }, + { + "timeSec": 9.5, + "relativeTime": 0.497, + "strength": "strong", + "energyDb": -16.7 + }, + { + "timeSec": 14, + "relativeTime": 0.732, + "strength": "strong", + "energyDb": -17.081 + } + ], + "events": [ + { + "kind": "audio_onset", + "timeSec": 0.5, + "relativeTime": 0.026, + "strength": "medium", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 1.633, + "relativeTime": 0.085, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 4.5, + "relativeTime": 0.235, + "strength": "weak", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "internal_motion", + "timeSec": 6.133, + "relativeTime": 0.321, + "strength": "weak", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 6.933, + "relativeTime": 0.362, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "internal_motion", + "timeSec": 7.467, + "relativeTime": 0.39, + "strength": "strong", + "direction": "unknown", + "description": "低阈值画面变化,推断为镜头内运动" + }, + { + "kind": "audio_onset", + "timeSec": 8.5, + "relativeTime": 0.444, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 9.5, + "relativeTime": 0.497, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + }, + { + "kind": "audio_onset", + "timeSec": 14, + "relativeTime": 0.732, + "strength": "strong", + "direction": "unknown", + "description": "真实音频能量峰,可作为模板运动或转场锚点" + } + ], + "strategySummary": "迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。", + "renderHints": [ + "full_bleed", + "ken_burns_in", + "cut", + "single_viewport", + "no_mask_reveal" + ] + }, + "learnedDimensions": { + "scriptStructure": { + "formula": "氛围感开篇引入 -> 多场景情绪铺垫 -> 第一视角日常代入 -> 首尾呼应情绪点题 -> 平台引流引导", + "segmentCount": 5, + "segments": [ + { + "role": "hook", + "label": "氛围感开篇引入", + "durationRatio": 0.16, + "intent": "快速建立都市夜生活的情绪基调,第一时间抓住偏好氛围感内容的用户注意力", + "copyPattern": "标志性场景快切开篇,用视觉冲击替代冗余台词", + "watchingPurpose": "开场抓停:快速建立都市夜生活的情绪基调,第一时间抓住偏好氛围感内容的用户注意力" + }, + { + "role": "setup", + "label": "多场景情绪铺垫", + "durationRatio": 0.29, + "intent": "通过不同的治愈系夜间画面堆叠,逐步烘托松弛的氛围感", + "copyPattern": "无叙事性蒙太奇串联碎片化风景画面", + "watchingPurpose": "建立背景:通过不同的治愈系夜间画面堆叠,逐步烘托松弛的氛围感" + }, + { + "role": "develop", + "label": "第一视角日常代入", + "durationRatio": 0.28, + "intent": "切换为普通人的出行第一视角,强化观众的沉浸式代入感", + "copyPattern": "第一视角记录日常出行片段,弱化刻意叙事感", + "watchingPurpose": "推进主体:切换为普通人的出行第一视角,强化观众的沉浸式代入感" + }, + { + "role": "climax", + "label": "首尾呼应情绪点题", + "durationRatio": 0.1, + "intent": "回到开篇场景,用文字点出核心情绪,完成共鸣触发", + "copyPattern": "场景闭环呼应+关键词文字点题", + "watchingPurpose": "放大重点:回到开篇场景,用文字点出核心情绪,完成共鸣触发" + }, + { + "role": "closing", + "label": "平台引流引导", + "durationRatio": 0.17, + "intent": "清晰告知用户相关内容的搜索路径,完成引流转化", + "copyPattern": "官方功能页直接展示操作指引", + "watchingPurpose": "收束记忆点:清晰告知用户相关内容的搜索路径,完成引流转化" + } + ], + "notes": [ + "脚本结构由 5 个段落组成,按 hook -> setup -> develop -> climax -> closing 推进。" + ] + }, + "shotRhythm": { + "durationSec": 19.133, + "shotCount": 8, + "avgShotSec": 2.39, + "cutDensity": "medium", + "peakAt": 0.75, + "beatHints": [ + "慢节奏氛围感BGM对齐镜头切点", + "情绪点题时刻BGM音量小幅抬升" + ], + "rhythmNotes": [ + "平均 2.4s/镜,整体为 中等节奏。", + "高潮位置约在全片 75%。" + ] + }, + "subtitleStyle": { + "density": "sparse", + "placement": "少量关键句上屏", + "typography": "与标题条风格「无显性顶部标题条,仅画面角落保留账号标识」协同", + "animation": "字幕/标题可能配合「硬切为主,情绪衔接段使用淡入淡出效果,收尾跳转使用快切」出现", + "notes": [ + "当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。" + ] + }, + "visualPackaging": { + "titleBarStyle": "无显性顶部标题条,仅画面角落保留账号标识", + "stickerUsage": "无额外装饰贴纸,仅叠加少量短句情绪文字", + "coverStyle": "选取视觉冲击力最强的月夜竹林帧作为封面,突出氛围感属性", + "overlayStyle": "无显性顶部标题条,仅画面角落保留账号标识 / 无额外装饰贴纸,仅叠加少量短句情绪文字", + "notes": [ + "画面包装迁移重点:无显性顶部标题条,仅画面角落保留账号标识;无额外装饰贴纸,仅叠加少量短句情绪文字;选取视觉冲击力最强的月夜竹林帧作为封面,突出氛围感属性", + "模板画幅:full_bleed,viewport=720:1280,Remotion 渲染可复用。" + ] + }, + "transitions": { + "style": "硬切为主,情绪衔接段使用淡入淡出效果,收尾跳转使用快切", + "frequency": "中等频率切换", + "notableTransitions": [ + "硬切为主,情绪衔接段使用淡入淡出效果,收尾跳转使用快切" + ], + "executableTechniques": [ + { + "id": "tech_transition_snap_cut", + "name": "可执行转场:snap_cut", + "triggerCondition": "cutDensity=medium 且 shot 边界需要承接情绪或信息切换", + "appliesToStoryFunctions": [ + "context", + "detail", + "proof", + "payoff", + "transition" + ], + "requiredRenderer": "both", + "transitionPreset": "snap_cut", + "implementationNotes": "已映射到 Timeline.transitionPreset=snap_cut,Remotion / FFmpeg fallback 均可执行。" + }, + { + "id": "tech_image_parallax_drift", + "name": "图片素材运镜:parallax_drift", + "triggerCondition": "image asset 用于 context/detail/payoff,且需要避免静态相册感", + "appliesToStoryFunctions": [ + "context", + "detail", + "payoff" + ], + "requiredRenderer": "both", + "motionPreset": "parallax_drift", + "implementationNotes": "已映射到 MotionPreset=parallax_drift,Remotion 执行 transform,FFmpeg fallback 执行 zoompan。" + }, + { + "id": "tech_card_soft_crossfade", + "name": "文字卡进出:soft_crossfade", + "triggerCondition": "text_card / packaging_overlay 用于 hook、转场或 CTA", + "appliesToStoryFunctions": [ + "opening_hook", + "transition", + "cta" + ], + "requiredRenderer": "remotion", + "cardAnimationPreset": "soft_crossfade", + "implementationNotes": "已映射到 Timeline.cardAnimationPreset=soft_crossfade,Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。" + } + ] + }, + "bgmSync": { + "hasAudio": true, + "beatHints": [ + "慢节奏氛围感BGM对齐镜头切点", + "情绪点题时刻BGM音量小幅抬升" + ], + "syncStrategy": "参考蓝图中的 2 条 BGM / 节奏卡点提示", + "confidence": "medium", + "limitations": [ + "BGM 卡点来自结构推断,未做精确音频 beat detect。" + ] + } + }, + "slotNeeds": [ + { + "slotId": "s1", + "segmentRole": "hook", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 3, + "optional": false + }, + { + "slotId": "s2", + "segmentRole": "setup", + "requiredAssetTypes": [ + "b_roll" + ], + "minDurationSec": 5, + "optional": false + }, + { + "slotId": "s3", + "segmentRole": "develop", + "requiredAssetTypes": [ + "talking_head", + "b_roll" + ], + "minDurationSec": 6, + "optional": false + }, + { + "slotId": "s4", + "segmentRole": "climax", + "requiredAssetTypes": [ + "b_roll", + "text_card" + ], + "minDurationSec": 2, + "optional": false + }, + { + "slotId": "s5", + "segmentRole": "closing", + "requiredAssetTypes": [ + "text_card" + ], + "minDurationSec": 3, + "optional": false + } + ], + "evidence": [ + { + "type": "duration", + "detail": "时长 19.133s", + "ref": "seed:v2800fgi0000d6udia7og65lv60bsos0.MP4" + }, + { + "type": "resolution", + "detail": "720x1280 @ 30fps" + }, + { + "type": "scene_cut", + "detail": "8 个镜头 / 7 个切点(原始 7 个,已合并 <0.4s 密集检测)", + "ref": "3.17, 4.40, 8.77, 10.87, 12.77, 14.17, 16.10" + }, + { + "type": "template_profile", + "detail": "full_bleed;内部运动 low;模板事件 9 个;音频 onset 5 个", + "ref": "full_bleed, ken_burns_in, cut, single_viewport, no_mask_reveal" + }, + { + "type": "keyframe", + "detail": "抽取 8 帧关键帧(≤12)" + }, + { + "type": "audio", + "detail": "含音轨 aac" + }, + { + "type": "asr", + "detail": "未配置 ASR endpoint,跳过转写" + } + ], + "rationale": "该结构适配情绪向vlog的内容逻辑,开篇用高辨识度场景快速筛选目标受众,中段用碎片化蒙太奇堆叠氛围感降低观众认知成本,通过第一视角片段强化代入感,在视频后段用首尾场景呼应触发情绪共鸣,最后直接给出清晰的搜索操作指引,全程节奏贴合慢氛围感BGM,无冗余信息,适配竖屏短视频用户的碎片化观看习惯,同时兼顾完播率和引流转化效率。", + "createdAt": "2026-06-08T10:13:48.537Z", + "updatedAt": "2026-06-08T10:17:13.526Z" + } +] diff --git a/apps/api/src/agents/__tests__/directorAgent.test.ts b/apps/api/src/agents/__tests__/directorAgent.test.ts new file mode 100644 index 0000000..b752510 --- /dev/null +++ b/apps/api/src/agents/__tests__/directorAgent.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { sampleBlueprint } from '../../core/mocks/sample-blueprint'; +import { runRuleBasedMigration } from '../../core/migration'; +import type { TaggedAsset } from '../../core/slot'; +import { DIRECTOR_SYSTEM_PROMPT, runDirectorAgent } from '../directorAgent'; + +const assets: TaggedAsset[] = [ + { id: 'asset_demo', mediaType: 'video', assetTags: ['b_roll'], durationSec: 8, confidence: 0.9, summary: '可用演示素材' }, +]; + +describe('runDirectorAgent', () => { + it('把用户需求、样例蓝图、样例库和素材交给 LLM,返回可执行 DirectorPlan', async () => { + const baseline = runRuleBasedMigration({ + projectId: 'p_director', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '新鲜咖啡豆', + sellingPoints: ['烘焙日期透明'], + durationSec: 12, + }).directorPlan; + + const plan = await runDirectorAgent( + { + projectId: 'p_director', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + learnedPatterns: [], + topic: '新鲜咖啡豆', + sellingPoints: ['烘焙日期透明'], + durationSec: 12, + baselineDirectorPlan: baseline, + }, + { + chatFn: async (messages) => { + expect(messages[0].content).toContain('Creative Director / Video Director'); + const user = String(messages[1].content); + expect(user).toContain('用户需求'); + expect(user).toContain('当前样例视频结构蓝图'); + expect(user).toContain('用户可用素材'); + return JSON.stringify({ + ...baseline, + id: 'dp_llm_test', + storyArc: { + opening: '先用过期咖啡的反差抓住注意力。', + setup: '建立新鲜烘焙才是香气来源的认知。', + progression: '用素材展示咖啡豆和冲煮过程。', + turn: '把日期透明作为信任转折。', + payoff: '收束到入口香气和购买理由。', + emotionalCurve: ['好奇', '理解', '信任', '行动'], + }, + shots: baseline.shots.map((shot, index) => + index === 1 + ? { + ...shot, + communicationIntent: '这一镜只靠画面建立香气感,不加文字。', + copyMode: 'none', + copyRequired: false, + } + : shot, + ), + rationale: 'LLM director 测试计划。', + }); + }, + }, + ); + + expect(DIRECTOR_SYSTEM_PROMPT).toContain('不能发明不存在的真实素材'); + expect(DIRECTOR_SYSTEM_PROMPT).toContain('copyMode'); + expect(plan.id).toBe('dp_llm_test'); + expect(plan.storyArc.turn).toContain('日期透明'); + expect(plan.shots.length).toBe(baseline.shots.length); + expect(plan.shots.some((shot) => shot.copyMode === 'none')).toBe(true); + }); +}); diff --git a/apps/api/src/agents/__tests__/expertMigration.test.ts b/apps/api/src/agents/__tests__/expertMigration.test.ts index e178559..3a04f0d 100644 --- a/apps/api/src/agents/__tests__/expertMigration.test.ts +++ b/apps/api/src/agents/__tests__/expertMigration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { sampleBlueprint } from '../../core/mocks/sample-blueprint'; +import { runRuleBasedMigration } from '../../core/migration'; import { validateMigrationPlan } from '../../core/validate'; import type { TaggedAsset } from '../../core/slot'; import { runExpertMigration } from '../expertMigration'; @@ -11,13 +12,17 @@ const assets: TaggedAsset[] = [ describe('runExpertMigration', () => { it('规则打底 + 专家文案:脚本被专家替换、rationale 用专家、计划仍合法', async () => { const segCount = sampleBlueprint.scriptStructure.segments.length; - const stub = JSON.stringify({ - segments: Array.from({ length: segCount }, (_, i) => ({ - scriptText: `专家文案 ${i}`, - visualDirection: `镜头方向 ${i}`, - })), - rationale: '观众视角的整体迁移思路。', - }); + const shotIds: string[] = []; + const visualOnlyIndex = 1; + const baselineDirectorPlan = runRuleBasedMigration({ + projectId: 'p', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '测试主题', + sellingPoints: ['要点A'], + durationSec: 30, + }).directorPlan; const plan = await runExpertMigration( { @@ -29,14 +34,116 @@ describe('runExpertMigration', () => { sellingPoints: ['要点A'], durationSec: 30, }, - { chatFn: async () => stub }, + { + directorChatFn: async (messages) => { + const user = String(messages.find((m) => m.role === 'user')?.content ?? ''); + expect(user).toContain('用户需求'); + expect(user).toContain('baselineDirectorPlan'); + return JSON.stringify({ + ...baselineDirectorPlan, + id: 'dp_llm_expert_test', + storyArc: { + ...baselineDirectorPlan.storyArc, + turn: 'LLM director 设计的转折', + emotionalCurve: ['抓停', '递进', '转折', '收束'], + }, + shots: baselineDirectorPlan.shots.map((shot, index) => + index === visualOnlyIndex + ? { + ...shot, + communicationIntent: '这一镜只靠画面呼吸,不需要文字。', + copyMode: 'none', + copyRequired: false, + } + : shot, + ), + rationale: 'LLM director 先设计剧情和 shot。', + }); + }, + expertChatFn: async (messages) => { + const user = messages.find((m) => m.role === 'user')?.content; + const text = typeof user === 'string' ? user : ''; + const match = text.match(/必须执行的 directed shots(顺序和 shotId 固定):\n(.+)$/s); + const shots = match ? (JSON.parse(match[1]) as Array<{ shotId: string }>) : []; + shotIds.splice(0, shotIds.length, ...shots.map((shot) => shot.shotId)); + return JSON.stringify({ + shots: shotIds.map((shotId, i) => ({ + shotId, + scriptText: `专家文案 ${i}`, + visualDirection: `镜头方向 ${i}`, + })), + rationale: '观众视角的整体迁移思路。', + }); + }, + }, ); expect(validateMigrationPlan(plan).ok).toBe(true); expect(plan.script[0].text).toBe('专家文案 0'); + expect(plan.script.map((line) => line.text).join(' ')).not.toContain(`专家文案 ${visualOnlyIndex}`); + expect(plan.storyboard.find((item) => item.shotId === shotIds[visualOnlyIndex])?.copy).toBe(''); expect(plan.storyboard[0].visual).toBe('镜头方向 0'); expect(plan.rationale).toBe('观众视角的整体迁移思路。'); - // 结构仍来自规则版 - expect(plan.timeline.items.length).toBe(segCount); + expect(plan.directorPlan.id).toBe('dp_llm_expert_test'); + expect(plan.directorPlan.storyArc.turn).toBe('LLM director 设计的转折'); + expect(plan.evidence.some((e) => e.type === 'director_agent' && e.detail.includes('LLM DirectorAgent'))).toBe(true); + // 结构来自前置 DirectorPlan,专家只按 shotId 写文案和分镜表达。 + expect(plan.directorPlan.shots.length).toBeGreaterThanOrEqual(segCount); + expect(plan.timeline.items.every((item) => item.track === 'audio' || item.shotRef)).toBe(true); + expect(plan.timeline.items.filter((item) => item.track !== 'audio').length).toBeGreaterThanOrEqual(segCount); + expect(plan.timeline.items.some((item) => item.motionPreset !== 'static')).toBe(true); + expect(plan.timeline.items.some((item) => item.track === 'audio')).toBe(true); + }); + + it('专家文案里的样例残留名词会被语义清洗', async () => { + const baselineDirectorPlan = runRuleBasedMigration({ + projectId: 'p_semantic', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '周末独自旅行', + durationSec: 12, + }).directorPlan; + + const plan = await runExpertMigration( + { + projectId: 'p_semantic', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + id: 'lake', + mediaType: 'image', + assetTags: ['b_roll'], + confidence: 0.9, + summary: '蓝绿色湖水、森林和旅行人物', + }, + ], + topic: '周末独自旅行', + durationSec: 12, + }, + { + directorChatFn: async () => JSON.stringify(baselineDirectorPlan), + expertChatFn: async () => + JSON.stringify({ + shots: baselineDirectorPlan.shots.map((shot) => ({ + shotId: shot.shotId, + screenText: '专属这座城的独家记忆:复古电车/限定路牌', + cardCopy: '专属这座城的独家记忆:复古电车/限定路牌', + scriptText: '专属这座城的独家记忆:复古电车/限定路牌', + })), + rationale: '测试语义清洗。', + }), + }, + ); + + const visibleText = [ + ...plan.script.map((line) => line.text), + ...plan.storyboard.map((item) => item.copy), + ...plan.fills.map((fill) => fill.displayText ?? ''), + ].join(' '); + expect(visibleText).not.toContain('复古电车'); + expect(visibleText).not.toContain('限定路牌'); + expect(visibleText).toContain('周末独自旅行'); }); }); diff --git a/apps/api/src/agents/__tests__/fillWithStock.test.ts b/apps/api/src/agents/__tests__/fillWithStock.test.ts new file mode 100644 index 0000000..6c43b0d --- /dev/null +++ b/apps/api/src/agents/__tests__/fillWithStock.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { sampleBlueprint } from '../../core/mocks/sample-blueprint'; +import { runRuleBasedMigration } from '../../core/migration'; +import type { TaggedAsset } from '../../core/slot'; +import { enhanceFillsWithStock } from '../fillWithStock'; + +// 空素材 → rule-based 回退到 text_card / copy_completion 文字承接,便于测试 enhancer。 +const assets: TaggedAsset[] = []; +const isTextOnly = (k: string) => k === 'text_card' || k === 'copy_completion'; + +const basePlan = () => + runRuleBasedMigration({ + projectId: 'p', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: 'F1 赛车冠军征程', + durationSec: 30, + }); + +describe('enhanceFillsWithStock', () => { + it('检索命中:text-only 缺口被替换为 stock_clip,记一条 decision', async () => { + const plan = basePlan(); + const tcCount = plan.fills.filter((f) => isTextOnly(f.kind)).length; + expect(tcCount).toBeGreaterThan(0); + + const enhanced = await enhanceFillsWithStock(plan, { + search: async () => ({ path: '/tmp/stock.mp4', attribution: 'Pexels stub', sourceUrl: 'https://x' }), + outDir: '/tmp', + blueprint: sampleBlueprint, + }); + + expect(enhanced.fills.filter((f) => f.kind === 'stock_clip')).toHaveLength(tcCount); + expect(enhanced.fills.filter((f) => isTextOnly(f.kind))).toHaveLength(0); + expect(enhanced.fills.every((f) => f.kind !== 'stock_clip' || f.source === '/tmp/stock.mp4')).toBe(true); + expect(enhanced.decisions.some((d) => d.chosen === 'stock_clip')).toBe(true); + }); + + it('检索全部 null:保留 text-only 缺口,计划不变', async () => { + const plan = basePlan(); + const enhanced = await enhanceFillsWithStock(plan, { + search: async () => null, + outDir: '/tmp', + blueprint: sampleBlueprint, + }); + expect(enhanced.fills.filter((f) => f.kind === 'stock_clip')).toHaveLength(0); + expect(enhanced.fills.filter((f) => isTextOnly(f.kind)).length).toBe( + plan.fills.filter((f) => isTextOnly(f.kind)).length, + ); + }); + + it('检索词来自 topic + 资产类型映射', async () => { + const plan = basePlan(); + const queries: string[] = []; + await enhanceFillsWithStock(plan, { + search: async (q) => { + queries.push(q); + return null; + }, + outDir: '/tmp', + blueprint: sampleBlueprint, + }); + // 至少有一个 query 同时含 topic 关键词 'F1' 和某个英文素材关键词 + const joined = queries.join(' | '); + expect(joined).toContain('F1'); + expect(/talking|product|comparison|b roll|using/i.test(joined)).toBe(true); + }); +}); diff --git a/apps/api/src/agents/__tests__/sampleLearningAgent.test.ts b/apps/api/src/agents/__tests__/sampleLearningAgent.test.ts new file mode 100644 index 0000000..1ab3a77 --- /dev/null +++ b/apps/api/src/agents/__tests__/sampleLearningAgent.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { sampleAnalysis } from '../../core/mocks/sample-analysis'; +import { sampleBlueprint } from '../../core/mocks/sample-blueprint'; +import { runSampleLearningAgent } from '../sampleLearningAgent'; + +describe('runSampleLearningAgent', () => { + it('拆分 story skeleton 与可执行 editing technique', () => { + const draft = runSampleLearningAgent({ + scope: 'global', + sample: { + id: 'sample_1', + filename: 'excellent.mp4', + analysis: sampleAnalysis, + blueprint: sampleBlueprint, + }, + }); + + expect(draft.dbData.storySkeleton?.segmentRoles).toEqual(['hook', 'setup', 'develop', 'climax', 'closing']); + expect(draft.dbData.editingTechniques.length).toBeGreaterThan(0); + expect(draft.dbData.editingTechniques.every((technique) => technique.requiredRenderer)).toBe(true); + expect(draft.dbData.musicFingerprint?.hasAudio).toBe(true); + expect(draft.dbData.rhythmProfile?.events.some((event) => event.eventType === 'cut')).toBe(true); + expect(draft.learnedThings.keyTakeaways.some((item) => item.startsWith('节奏迁移:'))).toBe(true); + expect(draft.learnedThings.recommendation.suggestedMode).toBe('both'); + expect(draft.learnedThings.keyTakeaways.some((item) => item.startsWith('剪辑语言:'))).toBe(true); + }); + + it('剪辑技巧无法映射 preset 时只提示 rejected,不写入可复用技巧库', () => { + const draft = runSampleLearningAgent({ + scope: 'global', + learnScope: { storySkeleton: false, editingTechniques: true, packagingStyle: false, bgmSync: false }, + sample: { + id: 'sample_2', + filename: 'complex.mp4', + analysis: sampleAnalysis, + blueprint: { + ...sampleBlueprint, + rhythmStructure: { ...sampleBlueprint.rhythmStructure, cutDensity: 'medium' }, + packagingStructure: { + ...sampleBlueprint.packagingStructure!, + transitionStyle: '复杂蒙版 tracking speed ramp 粒子转场', + }, + }, + }, + }); + + expect(draft.dbData.storySkeleton).toBeUndefined(); + expect(draft.dbData.editingTechniques.every((technique) => technique.transitionPreset !== 'whip_cut')).toBe(true); + expect(draft.learnedThings.rejectedTechniques.length).toBeGreaterThan(0); + expect(draft.learnedThings.rejectedTechniques[0].userMessage).toContain('不会写入全局可复用技巧库'); + }); + + it('单镜头样例把转场摘要升级为剪辑语言摘要', () => { + const draft = runSampleLearningAgent({ + scope: 'global', + learnScope: { storySkeleton: false, editingTechniques: true, packagingStyle: false, bgmSync: false }, + sample: { + id: 'sample_3', + filename: 'single-shot.mov', + analysis: { + ...sampleAnalysis, + metadata: { ...sampleAnalysis.metadata, durationSec: 7.64, width: 1280, height: 720 }, + scenes: [], + shotCount: 1, + keyframes: [sampleAnalysis.keyframes[0]], + }, + blueprint: { + ...sampleBlueprint, + rhythmStructure: { + ...sampleBlueprint.rhythmStructure, + avgShotSec: 7.64, + cutDensity: 'low', + shots: [ + { + startSec: 0, + endSec: 7.64, + visualRole: 'action', + shotScale: 'medium', + motionIntent: 'push_in', + transitionIntent: 'cut', + }, + ], + }, + packagingStructure: { + ...sampleBlueprint.packagingStructure!, + transitionStyle: '全程无转场效果,单镜头直出', + }, + }, + }, + }); + + const editingTakeaway = draft.learnedThings.keyTakeaways.find((item) => item.startsWith('剪辑语言:')); + expect(editingTakeaway).toContain('单镜头连续呈现'); + expect(editingTakeaway).toContain('未检测到镜头间转场'); + expect(editingTakeaway).toContain('保持稳定长镜头感'); + expect(editingTakeaway).not.toContain('转场抽象'); + }); + + it('模板化单镜头样例会保存 templateProfile 并学习内部运动', () => { + const draft = runSampleLearningAgent({ + scope: 'global', + learnScope: { storySkeleton: true, editingTechniques: true, packagingStyle: true, bgmSync: true }, + sample: { + id: 'sample_template', + filename: 'film-frame.mov', + analysis: { + ...sampleAnalysis, + scenes: [], + shotCount: 1, + templateProfile: { + id: 'tpl_sample_template', + source: 'user_sample', + durationSec: 5.8, + sourceAspect: '720:456', + targetCanvasAspect: '9:16', + layoutPreset: 'cinematic_matte', + frameStyle: { + backgroundColor: '#050505', + matte: true, + roundedMask: true, + labelStyle: 'film_code', + viewport: { aspectRatio: '16:9', x: 0.04, y: 0.18, width: 0.92, height: 0.58 }, + }, + motionLanguage: { + internalMotionIntensity: 'high', + hasMaskReveals: true, + hasViewportSlides: true, + preferredMotionPreset: 'reveal_pan', + preferredTransitionPreset: 'whip_cut', + notes: ['低阈值画面变化 8 个。'], + }, + audioOnsets: [{ timeSec: 0.8, relativeTime: 0.14, strength: 'strong', energyDb: -9 }], + events: [{ + kind: 'mask_reveal', + timeSec: 0.8, + relativeTime: 0.14, + strength: 'strong', + direction: 'left', + nearestOnsetSec: 0.8, + description: '模板遮罩', + }], + strategySummary: '保留黑底横版画幅框架。', + renderHints: ['cinematic_matte', 'reveal_pan'], + }, + }, + blueprint: sampleBlueprint, + }, + }); + + expect(draft.dbData.templateProfile?.layoutPreset).toBe('cinematic_matte'); + expect(draft.dbData.templateProfile?.source).toBe('global_sample'); + expect(draft.learnedThings.keyTakeaways.some((item) => item.startsWith('模板剪辑:'))).toBe(true); + const editingTakeaway = draft.learnedThings.keyTakeaways.find((item) => item.startsWith('剪辑语言:')); + expect(editingTakeaway).toContain('未检测到硬切'); + expect(editingTakeaway).toContain('cinematic_matte'); + }); + + it('入库质检会把薄样例、平台 CTA 和视觉桥接风险标出来', () => { + const draft = runSampleLearningAgent({ + scope: 'global', + learnScope: { storySkeleton: true, editingTechniques: true, packagingStyle: true, bgmSync: true }, + sample: { + id: 'sample_thin_platform', + filename: 'platform-follow.mov', + analysis: { + ...sampleAnalysis, + metadata: { ...sampleAnalysis.metadata, durationSec: 9.2, width: 1280, height: 720 }, + scenes: [{ index: 1, atSec: 4.5 }], + shotCount: 2, + }, + blueprint: { + ...sampleBlueprint, + videoGenre: 'showcase', + scriptStructure: { + segments: [ + { role: 'hook', label: '真人出镜开场', durationRatio: 0.35, intent: '用人物出镜抓住注意力', copyPattern: '人物身份引入' }, + { role: 'develop', label: '氛围场景展示', durationRatio: 0.4, intent: '展示街景氛围', copyPattern: '多场景情绪铺陈' }, + { role: 'closing', label: '平台搜索行动引导', durationRatio: 0.25, intent: '引导观众搜索账号获取教程', copyPattern: '搜索账号获取教程' }, + ], + }, + rhythmStructure: { + ...sampleBlueprint.rhythmStructure, + avgShotSec: 4.6, + cutDensity: 'low', + }, + packagingStructure: { + ...sampleBlueprint.packagingStructure!, + titleBarStyle: '角落账号标识和平台水印', + stickerUsage: '真人出镜叠加账号名', + }, + }, + }, + }); + + expect(draft.dbData.qualityTags.patternDepth).toBe('template_or_editing_only'); + expect(draft.dbData.qualityTags.ctaType).toBe('platform_follow'); + expect(draft.dbData.qualityTags.visualBridgePolicy.use).toBe('blocked'); + expect(draft.dbData.qualityTags.commercialUsefulness).toBe('not_recommended'); + expect(draft.dbData.storySkeleton).toBeUndefined(); + expect(draft.learnedThings.recommendation.suggestedMode).toBe('editingTechniques'); + expect(draft.learnedThings.risks.some((risk) => risk.includes('更适合学剪辑'))).toBe(true); + }); +}); diff --git a/apps/api/src/agents/__tests__/structureAgent.test.ts b/apps/api/src/agents/__tests__/structureAgent.test.ts index 4a7eceb..c786776 100644 --- a/apps/api/src/agents/__tests__/structureAgent.test.ts +++ b/apps/api/src/agents/__tests__/structureAgent.test.ts @@ -33,6 +33,93 @@ describe('runStructureAgent', () => { expect(bp.scriptStructure.segments.map((s) => s.role)).toEqual(['hook', 'develop', 'closing']); }); + it('容错归一化模型误填到 slot 的视觉角色', async () => { + const draft = JSON.stringify({ + ...JSON.parse(stubDraft), + slots: [ + { id: 'slot_action', segmentRole: 'develop', requiredAssetTypes: ['action'], optional: false }, + { id: 'slot_cta', segmentRole: 'closing', requiredAssetTypes: ['cta_card'], optional: false }, + ], + }); + + const bp = await runStructureAgent( + { analysis: sampleAnalysis }, + { chatFn: async () => draft }, + ); + + expect(validateBlueprint(bp).ok).toBe(true); + expect(bp.slots.find((slot) => slot.id === 'slot_action')?.requiredAssetTypes).toEqual(['usage_demo']); + expect(bp.slots.find((slot) => slot.id === 'slot_cta')?.requiredAssetTypes).toEqual(['text_card']); + }); + + it('容错把误填到 shotScale 的 static 归到 motionIntent', async () => { + const draft = JSON.stringify({ + ...JSON.parse(stubDraft), + scriptStructure: { + segments: [ + { role: 'hook', durationRatio: 0.15, intent: '制造好奇', copyPattern: '反常识疑问句' }, + { + role: 'develop', + durationRatio: 0.55, + intent: '推进内容', + copyPattern: '逐步揭示', + shotScale: 'static', + }, + { role: 'closing', durationRatio: 0.3, intent: '情绪落点', copyPattern: '主题升华' }, + ], + }, + rhythmStructure: { + avgShotSec: 2, + cutDensity: 'high', + peakAt: 0.6, + bgmBeatHints: [], + shots: [ + { startSec: 0, endSec: 1.8, shotScale: 'wide', motionIntent: 'push_in' }, + { startSec: 1.8, endSec: 3.4, shotScale: 'static' }, + ], + }, + }); + + const bp = await runStructureAgent( + { analysis: sampleAnalysis }, + { chatFn: async () => draft }, + ); + + expect(validateBlueprint(bp).ok).toBe(true); + expect(bp.scriptStructure.segments[1].shotScale).toBeUndefined(); + expect(bp.scriptStructure.segments[1].motionIntent).toBe('static'); + expect(bp.rhythmStructure.shots?.[1].shotScale).toBeUndefined(); + expect(bp.rhythmStructure.shots?.[1].motionIntent).toBe('static'); + }); + + it('容错把 captionStyle 的横向位置归一到合法 placement', async () => { + const draft = JSON.stringify({ + ...JSON.parse(stubDraft), + scriptStructure: { + segments: [ + { + role: 'hook', + durationRatio: 0.15, + intent: '制造好奇', + copyPattern: '反常识疑问句', + captionStyle: { placement: 'right', density: 'high', bilingualLike: false }, + }, + { role: 'develop', durationRatio: 0.55, intent: '推进内容', copyPattern: '逐步揭示' }, + { role: 'closing', durationRatio: 0.3, intent: '情绪落点', copyPattern: '主题升华' }, + ], + }, + }); + + const bp = await runStructureAgent( + { analysis: sampleAnalysis }, + { chatFn: async () => draft }, + ); + + expect(validateBlueprint(bp).ok).toBe(true); + expect(bp.scriptStructure.segments[0].captionStyle?.placement).toBe('center'); + expect(bp.scriptStructure.segments[0].captionStyle?.density).toBe('dense'); + }); + it('模型输出非法时重试,仍失败则抛错', async () => { await expect( runStructureAgent({ analysis: sampleAnalysis }, { chatFn: async () => '{ not json' }), diff --git a/apps/api/src/agents/describeFrames.ts b/apps/api/src/agents/describeFrames.ts index 7f28eec..fbce49d 100644 --- a/apps/api/src/agents/describeFrames.ts +++ b/apps/api/src/agents/describeFrames.ts @@ -56,7 +56,7 @@ export async function describeFrames( { role: 'system', content: SYSTEM }, { role: 'user', content }, ], - { chatFn: opts.chatFn, temperature: 0.2, maxTokens: 1500 }, + { chatFn: opts.chatFn, temperature: 0.2, maxTokens: 1500, traceName: 'describe_frames' }, ); const shotDescriptions = keyframes.map((k, i) => `@${k.atSec}s: ${result.frames[i] ?? '(无描述)'}`); diff --git a/apps/api/src/agents/directorAgent.ts b/apps/api/src/agents/directorAgent.ts new file mode 100644 index 0000000..bc6c2c7 --- /dev/null +++ b/apps/api/src/agents/directorAgent.ts @@ -0,0 +1,203 @@ +import { z } from 'zod'; +import { DirectorPlan, type DirectorPlan as DirectorPlanT } from '../core/director'; +import { jsonSchemas } from '../core/jsonSchema'; +import { rankLearnedPatterns } from '../core/migration'; +import type { LearnedSamplePattern } from '../core/sampleLearning'; +import type { ReferenceAsset, TaggedAsset } from '../core/slot'; +import type { VideoStructureBlueprint } from '../core/blueprint'; +import type { MigrationIntent, ReferenceClipMode, TemplateAdaptationMode, VisualGapMode } from '../core/enums'; +import { type ChatFn, chatJson } from '../llm/ark'; + +export interface DirectorAgentInput { + projectId: string; + sampleId: string; + blueprint: VideoStructureBlueprint; + assets: TaggedAsset[]; + referenceAssets?: ReferenceAsset[]; + learnedPatterns?: LearnedSamplePattern[]; + topic: string; + sellingPoints?: string[]; + durationSec?: number; + migrationIntent?: MigrationIntent; + referenceClipMode?: ReferenceClipMode; + visualGapMode?: VisualGapMode; + templateAdaptationMode?: TemplateAdaptationMode; + /** 启发式 DirectorPlan 基线:LLM 可以改 storyArc / shots,但必须保持可执行 schema。 */ + baselineDirectorPlan: DirectorPlanT; +} + +export interface DirectorAgentOptions { + chatFn?: ChatFn; +} + +export const DIRECTOR_SYSTEM_PROMPT = [ + '你是短视频 Creative Director / Video Director。', + '你的任务:根据用户需求、当前样例视频抽出的结构蓝图、已学习样例库 pattern、以及用户可用素材,设计一条可执行的新视频剧情线和 shot 计划。', + '学习样例库 pattern 里的 learnedDimensions 必须作为重要参考:脚本结构、镜头节奏、字幕样式、画面包装、转场、BGM 卡点都可以迁移为方法。', + '你迁移的是样例的结构方法、节奏方法和包装方法,不复制样例原文、样例画面或样例剧情。', + '如果用户只给了主题 / 素材,没有明确 brief 或卖点要求,你必须默认沿用当前样例视频的故事弧线和段落功能:先判断样例“在讲什么故事 / 先发生什么 / 如何推进 / 在哪里反转或证明 / 如何收束”,再把同类故事迁移到新主题和用户素材上。', + '在上述默认模式下,可以迁移样例的叙事关系和情绪推进,但仍不能复制样例原文、专有事实或具体画面;应把 storyBeat 写成“样例段落功能 -> 新主题对应段落”。', + '素材不足时先评估素材预算,而不是硬补齐原样例结构;可改写缺口用重排/细节 montage/标题卡,可生成缺口用包装或 AIGC,真实证明/人物/CTA 等不可伪造缺口必须降级说明或提示补拍。', + '若 visualGapMode=user_only,只用用户素材并用重排/文案/包装降级;smart_fill 优先把样例方法转译成用户素材重组、包装或可生成补位;reference_bridge 也只能把样例/优质案例画面作为短暂氛围/尺度/转场桥接,不能伪造核心事实。', + '若 migrationIntent=story_only,不要强套样例或全局样例的转场/画幅模板;若 editing_only,故事按用户主题重写;story_and_editing 才同时迁移故事和剪辑方法。', + '每个 shot 必须写 visualFunctions,使用通用枚举 establish_context / introduce_subject / show_action / show_detail / show_progression / show_result / show_emotion / show_scale / bridge_transition / call_to_action。', + '不能发明不存在的真实素材;如果素材不足,必须在 shot.fallbackStrategies 中选择 structure_reframe / copy_completion / packaging_overlay / aigc / reference_clip / reused_clip 等补全策略。', + 'storyArc 必须先把新片讲成一个完整观看路径:opening / setup / progression / turn / payoff / emotionalCurve。', + '每个 shot 必须有明确 storyBeat、purpose、visualDirection、visualRole、shotScale、communicationIntent、copyMode、copyRequired、screenTextIntent、assetNeed、preferredAssetIds、fallbackStrategies、mustShow。', + 'visualRole / shotScale / motionPreset / transitionPreset 要体现样例镜头语言:例如建立场景用 wide + pan/push,细节证明用 close + push/snap,信息卡和 CTA 保持稳定或轻转场;不要把所有图片都规划成同一种简单放大。', + 'copyMode 决定语言分工:none=纯视觉/留白,不要给 Expert 硬写文案;subtitle/caption/voiceover/screen_text/title_card 才需要 Expert 写具体文字。', + '字幕 / 上屏文字必须继承样例的用法,不要默认给每个镜头加字幕:参考蓝图 packagingStructure.subtitleDensity 和各段 captionStyle.placement —— 当样例某段 placement=none、或整体 subtitleDensity=sparse 且该段无文字时,对应 shot 的 copyMode 必须设为 none、copyRequired=false,只靠画面与剪辑表达;只有样例确实在该位置使用文字(如片尾落版标题、转场地名卡),或槽位本身是结构性 text_card 时,才使用带文字的 copyMode。', + '即使样例有字幕,也只迁移其「文字功能 / 出现位置」(如只在开头一句钩子、只在转场打地名),措辞按新主题与用户素材重写,不照搬样例原文或同一种阐述方式。', + 'shot 的时间段必须覆盖全片且按顺序排列;shotId 必须稳定唯一。', + '输出必须是合法 DirectorPlan JSON;不要输出解释文字,不要代码块标记。', +].join('\n'); + +/** LLM DirectorAgent:在 migration 前生成真正主控 storyArc / directed shots。 */ +export async function runDirectorAgent( + input: DirectorAgentInput, + opts: DirectorAgentOptions = {}, +): Promise { + const sellingPoints = (input.sellingPoints ?? []).map((point) => point.trim()).filter(Boolean); + const rankedPatterns = rankLearnedPatterns(input.learnedPatterns ?? [], input.blueprint, { + topic: input.topic, + sellingPoints, + purpose: input.migrationIntent === 'editing_only' ? 'editing' : 'story', + }).slice(0, 3); + const storySkeletonCandidates = rankedPatterns.filter((pattern) => pattern.learnScope?.storySkeleton !== false && pattern.storySkeleton).slice(0, 2); + const editingTechniqueCandidates = rankedPatterns + .filter((pattern) => pattern.learnScope?.editingTechniques !== false && pattern.editingTechniques.length > 0) + .slice(0, 3); + + const user = [ + '请生成一个 DirectorPlan。你可以参考 baselineDirectorPlan 的 timing / editConstraints / fallback policy,但需要重新设计更贴合用户需求和素材条件的 storyArc 与 shot 叙事。', + '优先级必须固定为:用户明确要求 > 当前样例故事骨架 > 用户素材约束 > 全局故事骨架 > 全局剪辑技巧。', + '全局 storySkeleton 只在当前样例结构弱、故事不完整、用户素材无法支撑关键 storyFunction、或用户没有要求严格迁移当前样例时作为兜底;全局 editingTechniques 可以更常用,因为它们只影响怎么剪,不改故事主导权。', + '', + `DirectorPlan JSON Schema:\n${JSON.stringify(jsonSchemas.DirectorPlan)}`, + '', + `用户需求:\n${JSON.stringify( + { + projectId: input.projectId, + sampleId: input.sampleId, + topic: input.topic, + sellingPoints, + migrationIntent: input.migrationIntent ?? 'story_and_editing', + visualGapMode: input.visualGapMode ?? (input.referenceClipMode === 'allow_reference_clip' ? 'reference_bridge' : 'smart_fill'), + referenceClipMode: input.referenceClipMode ?? 'learn_only', + templateAdaptationMode: input.templateAdaptationMode ?? 'auto', + explicitDirection: Boolean(sellingPoints.length), + defaultWhenNoExplicitDirection: + sellingPoints.length === 0 + ? '按当前样例视频的故事弧线和段落功能迁移,再结合用户素材写类似的新故事。' + : undefined, + durationSec: input.durationSec ?? input.baselineDirectorPlan.editConstraints.durationSec, + }, + null, + 2, + )}`, + '', + `当前样例视频结构蓝图(由样例视频分析得来,作为结构参考,不代表可直接复用画面):\n${JSON.stringify( + { + id: input.blueprint.id, + videoGenre: input.blueprint.videoGenre, + segments: input.blueprint.scriptStructure.segments, + rhythmStructure: input.blueprint.rhythmStructure, + packagingStructure: input.blueprint.packagingStructure, + slots: input.blueprint.slots, + evidence: input.blueprint.evidence.slice(0, 10), + rationale: input.blueprint.rationale, + }, + null, + 2, + )}`, + '', + `已学习样例库 patterns(仅迁移方法,不复制内容;storySkeleton 是低优先级兜底,editingTechniques 是高频剪辑增强):\n${JSON.stringify( + rankedPatterns.map((pattern, index) => ({ + rank: index + 1, + id: pattern.id, + name: pattern.reusablePatternName, + formula: pattern.formula, + genre: pattern.videoGenre, + learnScope: pattern.learnScope, + qualityTags: pattern.qualityTags, + storySkeleton: pattern.storySkeleton, + editingTechniques: pattern.editingTechniques, + pacing: pattern.pacing, + packaging: pattern.packaging, + learnedDimensions: pattern.learnedDimensions, + slotNeeds: pattern.slotNeeds, + })), + null, + 2, + )}`, + '', + `GlobalPatternRetriever 输出:\n${JSON.stringify( + { + storySkeletonCandidates: storySkeletonCandidates.map((pattern) => ({ + id: pattern.id, + name: pattern.reusablePatternName, + storySkeleton: pattern.storySkeleton, + })), + editingTechniqueCandidates: editingTechniqueCandidates.map((pattern) => ({ + id: pattern.id, + name: pattern.reusablePatternName, + editingTechniques: pattern.editingTechniques, + })), + }, + null, + 2, + )}`, + '', + `用户可用素材(主素材来源,优先使用这些 assetId):\n${JSON.stringify( + input.assets.map((asset) => ({ + id: asset.id, + mediaType: asset.mediaType, + assetTags: asset.assetTags, + storyRoles: asset.storyRoles, + visualFunctions: asset.visualFunctions, + shotScale: asset.shotScale, + aspectRatio: asset.aspectRatio, + durationSec: asset.durationSec, + confidence: asset.confidence, + summary: asset.summary, + })), + null, + 2, + )}`, + '', + `可低优先级复用的参考素材(来自当前样例或学习样例;仅在补全策略影响流畅性/结构时使用):\n${JSON.stringify( + (input.referenceAssets ?? []).map((asset) => ({ + id: asset.id, + sourceRole: asset.sourceRole, + sourceSampleId: asset.sourceSampleId, + patternId: asset.patternId, + mediaType: asset.mediaType, + assetTags: asset.assetTags, + durationSec: asset.durationSec, + confidence: asset.confidence, + summary: asset.summary, + })), + null, + 2, + )}`, + '', + `baselineDirectorPlan(可作为 timing / schema / fallback 参考):\n${JSON.stringify(input.baselineDirectorPlan, null, 2)}`, + ].join('\n'); + + const plan = await chatJson( + DirectorPlan, + [ + { role: 'system', content: DIRECTOR_SYSTEM_PROMPT }, + { role: 'user', content: user }, + ], + { chatFn: opts.chatFn, temperature: 0.45, maxTokens: 4096, traceName: 'director_agent' }, + ); + + return DirectorPlan.parse({ + ...plan, + evidence: plan.evidence.length ? plan.evidence : input.baselineDirectorPlan.evidence, + selectedPatternId: plan.selectedPatternId ?? input.baselineDirectorPlan.selectedPatternId, + }); +} + +export type DirectorAgentResult = z.infer; diff --git a/apps/api/src/agents/expertMigration.ts b/apps/api/src/agents/expertMigration.ts index 9902457..5e15b8d 100644 --- a/apps/api/src/agents/expertMigration.ts +++ b/apps/api/src/agents/expertMigration.ts @@ -1,18 +1,31 @@ import { z } from 'zod'; import type { VideoStructureBlueprint } from '../core/blueprint'; -import { MigrationPlan, runRuleBasedMigration } from '../core/migration'; -import type { TaggedAsset } from '../core/slot'; +import { buildDirectorArtifacts } from '../core/director'; +import { MigrationPlan, rankLearnedPatterns, runRuleBasedMigration } from '../core/migration'; +import { sanitizeViewerCopy } from '../core/semanticCopy'; +import type { SampleAnalysis } from '../core/sample'; +import type { BeatGrid } from '../core/timeline'; +import type { MigrationIntent, ReferenceClipMode, TemplateAdaptationMode, VisualGapMode } from '../core/enums'; +import type { MigrationControlsInput } from '../core/migrationControls'; +import type { LearnedSamplePattern } from '../core/sampleLearning'; +import type { ReferenceAsset, TaggedAsset } from '../core/slot'; import { type ChatFn, chatJson } from '../llm/ark'; +import { runDirectorAgent } from './directorAgent'; import { loadEditingPlaybook } from './editingKb'; const ExpertOutput = z.object({ - /** 与给定段落等长、顺序对应。 */ - segments: z.array( + /** 与 DirectorPlan.shots 的 shotId 对齐。 */ + shots: z.array( z.object({ - scriptText: z.string(), - visualDirection: z.string(), + shotId: z.string(), + voiceoverScript: z.string().optional(), + cardCopy: z.string().optional(), + scriptText: z.string().optional(), + visualDirection: z.string().optional(), + screenText: z.string().optional(), }), ), + conflicts: z.array(z.string()).default([]), /** 观众视角的整体迁移思路。 */ rationale: z.string(), }); @@ -21,17 +34,33 @@ export interface ExpertMigrationInput { projectId: string; sampleId: string; blueprint: VideoStructureBlueprint; + /** 当前样例机器分析结果,透传给规则迁移以复用 templateProfile。 */ + sampleAnalysis?: SampleAnalysis; assets: TaggedAsset[]; + /** 当前样例 / 学习样例中的可低优先级复用素材。 */ + referenceAssets?: ReferenceAsset[]; + /** 从全局 / 项目样例库检索出的可复用结构 pattern。 */ + learnedPatterns?: LearnedSamplePattern[]; topic: string; /** 要点 / 卖点(带货时即卖点)。 */ sellingPoints?: string[]; /** 主题构思助手产出的创意 brief(可选)。 */ brief?: string; durationSec?: number; + migrationIntent?: MigrationIntent; + referenceClipMode?: ReferenceClipMode; + visualGapMode?: VisualGapMode; + templateAdaptationMode?: TemplateAdaptationMode; + /** 是否把上传的音频素材或视频原声复用为 timeline BGM。默认开启。 */ + reuseUploadedBgm?: boolean; + detectedBeatGrid?: BeatGrid; + migrationControls?: MigrationControlsInput; } export interface ExpertMigrationOptions { chatFn?: ChatFn; + directorChatFn?: ChatFn; + expertChatFn?: ChatFn; } /** @@ -42,29 +71,97 @@ export async function runExpertMigration( input: ExpertMigrationInput, opts: ExpertMigrationOptions = {}, ): Promise> { - const base = runRuleBasedMigration(input); + const heuristicBase = runRuleBasedMigration(input); + let directorPlan = heuristicBase.directorPlan; + let directorSource: 'llm' | 'heuristic_fallback' = 'heuristic_fallback'; + let directorError: string | undefined; + + try { + directorPlan = await runDirectorAgent( + { + ...input, + baselineDirectorPlan: heuristicBase.directorPlan, + }, + { chatFn: opts.directorChatFn ?? opts.chatFn }, + ); + directorSource = 'llm'; + } catch (e) { + directorError = e instanceof Error ? e.message : String(e); + } + + const base = runRuleBasedMigration({ ...input, directorPlan }); const playbook = loadEditingPlaybook(input.blueprint.videoGenre); + const patternCtx = rankLearnedPatterns(input.learnedPatterns ?? [], input.blueprint, { + topic: input.topic, + sellingPoints: input.sellingPoints ?? [], + purpose: input.migrationIntent === 'editing_only' ? 'editing' : 'story', + }).slice(0, 3).map((pattern, i) => ({ + rank: i + 1, + id: pattern.id, + name: pattern.reusablePatternName, + formula: pattern.formula, + genre: pattern.videoGenre, + learnScope: pattern.learnScope, + qualityTags: pattern.qualityTags, + storySkeleton: pattern.storySkeleton, + editingTechniques: pattern.editingTechniques, + cutDensity: pattern.pacing.cutDensity, + avgShotSec: pattern.pacing.avgShotSec, + packaging: pattern.packaging + ? { + titleBarStyle: pattern.packaging.titleBarStyle, + stickerUsage: pattern.packaging.stickerUsage, + transitionStyle: pattern.packaging.transitionStyle, + coverStyle: pattern.packaging.coverStyle, + } + : undefined, + learnedDimensions: pattern.learnedDimensions, + slotNeeds: pattern.slotNeeds.map((slot) => ({ + role: slot.segmentRole, + tags: slot.requiredAssetTypes, + optional: slot.optional, + })), + })); - const segCtx = base.script.map((line, i) => { - const seg = input.blueprint.scriptStructure.segments[i]; - const sb = base.storyboard[i]; + const shotCtx = base.directorPlan.shots.map((shot) => { return { - idx: i, - role: line.segmentRole, - label: seg?.label, - intent: seg?.intent, - copyPattern: seg?.copyPattern, - span: `${line.startSec}-${line.endSec}s`, - slot: sb?.slotId, - visualHint: sb?.visual, + shotId: shot.shotId, + role: shot.segmentRole, + span: `${shot.startSec}-${shot.endSec}s`, + purpose: shot.purpose, + storyBeat: shot.storyBeat, + assetNeed: shot.assetNeed, + preferredAssetIds: shot.preferredAssetIds, + fallbackStrategies: shot.fallbackStrategies, + motionPreset: shot.motionPreset, + cropPreset: shot.cropPreset, + transitionPreset: shot.transitionPreset, + mustShow: shot.mustShow, + communicationIntent: shot.communicationIntent, + copyMode: shot.copyMode, + copyRequired: shot.copyRequired, + copyPurpose: shot.copyPurpose, + screenTextIntent: shot.screenTextIntent, + directorVisualDirection: shot.visualDirection, + visualFunctions: shot.visualFunctions, }; }); const system = [ - '你是资深短视频剪辑导演。任务:把样例的结构蓝图迁移到新主题,写出贴合的脚本与镜头方向。', + '你是资深短视频编剧与分镜执行专家。任务:严格执行 DirectorPlan,把导演已规划好的 shot 写成贴合新主题的脚本与分镜表达。', '严格遵循下面的剪辑知识库;服务对象是观众 / 观看者;迁移的是结构与表达套路,不是样例原文。', - '保持给定的段落顺序与时间分配;为每段产出 scriptText(口播 / 字幕文案)与 visualDirection(这段画面怎么拍 / 怎么用素材,缺素材就说怎么承接)。', - '只输出 JSON:{ "segments": [{"scriptText","visualDirection"}](长度与给定段落一致、顺序对应), "rationale": 用观众视角解释整体迁移思路 }。不要代码块标记。', + '不得改 shotId、顺序、时间分配、assetNeed、fallbackStrategies、copyMode/copyRequired 或运镜约束;如果发现不可执行,只能写入 conflicts,不要自行发明不存在的素材。', + '只为 copyRequired=true 且 copyMode != none 的 shot 写对应字段:subtitle/caption/screen_text 写 screenText,voiceover 写 voiceoverScript,title_card 写 cardCopy;copyMode=none 的 shot 不要硬写文案,可省略该 shot 或只补 visualDirection。', + 'visualDirection 只在你能比 DirectorPlan 更具体地说明“如何使用已有素材或补全承接”时填写;否则可省略。', + '优先级必须固定为:用户明确要求 > 当前样例故事骨架 > 用户素材约束 > 全局故事骨架 > 全局剪辑技巧。', + '迁移意图控制必须遵守:story_only 只学故事叙述,不强套样例转场/画幅;editing_only 只学剪辑方法,故事按用户主题重写;story_and_editing 才同时迁移两者。', + '素材不足策略必须遵守:user_only 只用用户素材并用重排/文案/包装降级;smart_fill 优先把样例方法转译成用户素材重组、包装或可生成补位;reference_bridge 只能把样例/优质案例画面作为短暂氛围/尺度/转场桥接,不能用来伪造人物、结果证明、商品效果或 CTA。', + '如果样例库 pattern 带有 storySkeleton,它只能在当前样例结构弱、故事不完整、素材无法支撑关键 storyFunction 或用户未要求严格迁移当前样例时兜底;不要让全局库抢当前样例主导权。', + '如果样例库 pattern 带有 editingTechniques,可以更常用地借鉴 motion / transition / beatPlacement / cardAnimationPreset,因为它们只改变怎么剪,不改变故事。', + '如果样例库 pattern 带有 learnedDimensions,请参考其中的脚本结构、镜头节奏、字幕样式、画面包装、转场、BGM 卡点;只迁移方法,不复制样例原文。', + '如果用户没有给明确 brief 或卖点,你要把当前样例蓝图中的段落 label / intent / copyPattern 当成默认故事母版:理解样例先讲什么、怎么推进、在哪里反转或证明、如何收束,再结合用户素材写类似的新故事。', + '素材缺口优先用结构重排、文案补全、包装补全、AIGC 生成补全来解决;只有 reference_bridge 且缺的是 establish_context/show_scale/show_emotion/bridge_transition 这类可桥接功能时,才接受 DirectorPlan 中的 reference_clip。', + '只输出 JSON:{ "shots": [{"shotId","voiceoverScript?","screenText?","cardCopy?","scriptText?","visualDirection?"}](至少覆盖所有需要文案的 shotId), "conflicts": string[], "rationale": 用观众视角解释整体迁移思路 }。不要代码块标记。', '', '【剪辑知识库】', playbook, @@ -73,10 +170,42 @@ export async function runExpertMigration( const user = [ `体裁: ${input.blueprint.videoGenre ?? 'other'}`, `新主题: ${input.topic}`, + `迁移意图: ${input.migrationIntent ?? 'story_and_editing'}`, + `素材不足策略: ${input.visualGapMode ?? (input.referenceClipMode === 'allow_reference_clip' ? 'reference_bridge' : 'smart_fill')}`, + `参考画面策略: ${input.referenceClipMode ?? 'learn_only'}`, + `模板适配策略: ${input.templateAdaptationMode ?? 'auto'}`, input.sellingPoints?.length ? `要点: ${input.sellingPoints.join(';')}` : '', input.brief ? `创意 brief: ${input.brief}` : '', + !input.brief && !input.sellingPoints?.length + ? `默认迁移策略: 用户没有明确卖点/brief,请按样例蓝图的故事弧线迁移。样例段落:\n${JSON.stringify( + input.blueprint.scriptStructure.segments.map((segment) => ({ + role: segment.role, + label: segment.label, + intent: segment.intent, + copyPattern: segment.copyPattern, + })), + )}` + : '', `可用素材: ${input.assets.map((a) => `${a.summary}[${a.assetTags.join('/')}]`).join(';') || '(较少,可能有缺口)'}`, - `要迁移的段落(顺序固定,请按 idx 顺序输出等长 segments):\n${JSON.stringify(segCtx)}`, + input.referenceAssets?.length + ? `低优先级参考素材(仅在结构重排/文案/包装/AIGC 补全不足以流畅成片时使用):\n${JSON.stringify( + input.referenceAssets.map((asset) => ({ + id: asset.id, + sourceRole: asset.sourceRole, + sourceSampleId: asset.sourceSampleId, + patternId: asset.patternId, + summary: asset.summary, + tags: asset.assetTags, + })), + )}` + : '', + `DirectorPlan 故事线:\n${JSON.stringify(base.directorPlan.storyArc)}`, + `DirectorPlan 素材预算:\n${JSON.stringify(base.directorPlan.assetBudget)}`, + `DirectorPlan 补全策略:\n${JSON.stringify(base.directorPlan.fillPolicy)}`, + patternCtx.length + ? `已检索到的样例库 pattern(优先迁移其结构方法,不复制原画面):\n${JSON.stringify(patternCtx)}` + : '', + `必须执行的 directed shots(顺序和 shotId 固定):\n${JSON.stringify(shotCtx)}`, ] .filter(Boolean) .join('\n'); @@ -87,25 +216,89 @@ export async function runExpertMigration( { role: 'system', content: system }, { role: 'user', content: user }, ], - { chatFn: opts.chatFn, temperature: 0.5, maxTokens: 2048 }, + { chatFn: opts.expertChatFn ?? opts.chatFn, temperature: 0.5, maxTokens: 2048, traceName: 'expert_migration' }, + ); + + const expertByShot = new Map(expert.shots.map((shot) => [shot.shotId, shot])); + const baseCopyByShot = new Map(base.storyboard.map((item) => [item.shotId, copyFieldsFrom(item)])); + const copyFieldsByShot = new Map( + base.directorPlan.shots.map((shot) => { + if (!shotNeedsCopy(shot)) return [shot.shotId, emptyCopyFields()] as const; + const expertShot = expertByShot.get(shot.shotId); + const fallback = baseCopyByShot.get(shot.shotId) ?? emptyCopyFields(); + return [ + shot.shotId, + copyFieldsForMode(shot.copyMode, expertShot, fallback, { + topic: input.topic, + sellingPoints: input.sellingPoints ?? [], + assetSummaries: input.assets.map((asset) => asset.summary), + }), + ] as const; + }), ); - // 规则结构 + 专家文案 / 镜头方向 + 专家迁移思路 - const script = base.script.map((line, i) => ({ + // DirectorPlan 结构 + 专家 shot 文案 / 镜头表达 + 专家迁移思路 + const script = base.script.map((line) => ({ ...line, - text: expert.segments[i]?.scriptText ?? line.text, + ...mergeCopyFields( + base.directorPlan.shots + .filter((shot) => shot.segmentRole === line.segmentRole && shotNeedsCopy(shot) && rangesOverlap(shot.startSec, shot.endSec, line.startSec, line.endSec)) + .map((shot) => copyFieldsByShot.get(shot.shotId) ?? emptyCopyFields()), + ), + })).map((line) => ({ + ...line, + text: displayCopy(line), })); - const storyboard = base.storyboard.map((sb, i) => ({ + const storyboard = base.storyboard.map((sb) => ({ ...sb, - copy: expert.segments[i]?.scriptText ?? sb.copy, - visual: expert.segments[i]?.visualDirection ?? sb.visual, + ...(sb.shotId ? (copyFieldsByShot.get(sb.shotId) ?? emptyCopyFields()) : copyFieldsFrom(sb)), + copy: sb.shotId ? displayCopy(copyFieldsByShot.get(sb.shotId) ?? emptyCopyFields()) : sb.copy, + visual: (sb.shotId ? expertByShot.get(sb.shotId)?.visualDirection : undefined) ?? sb.visual, })); - // 文字卡缺口:用专家文案作为卡片内容,渲染出真正的文字卡而非占位 + // 文字卡 / 包装补全:用专家的观众可见文案替换所有 textcard 补全,避免内部占位泄漏。 const fills = base.fills.map((f) => { - if (f.kind !== 'text_card') return f; - const sb = storyboard.find((s) => s.slotId === f.slotId); - return sb?.copy ? { ...f, source: `textcard://${encodeURIComponent(sb.copy)}` } : f; + if (!f.source.startsWith('textcard://')) return f; + const sb = storyboard.find((s) => s.slotId === f.slotId || s.shotId === f.slotId); + const displayText = (sb?.cardCopy || sb?.screenText || '').trim(); + return displayText + ? { ...f, displayText, source: stableTextCardSource(f.source, f.id) } + : f; + }); + const directorEvidence = { + type: 'director_agent', + detail: + directorSource === 'llm' + ? 'LLM DirectorAgent 已根据用户需求、样例蓝图、样例库 pattern 和可用素材生成 DirectorPlan;Migration 按该计划执行。' + : `DirectorAgent 未产出可用计划,已回退启发式 DirectorPlan:${directorError ?? 'unknown error'}`, + ref: base.directorPlan.id, + }; + const evidence = [ + ...base.evidence, + directorEvidence, + ...(expert.conflicts.length + ? [ + { + type: 'expert_conflict', + detail: `ExpertMigration 报告 ${expert.conflicts.length} 个导演执行冲突:${expert.conflicts.join(';')}`, + }, + ] + : []), + ]; + const directorArtifacts = buildDirectorArtifacts({ + blueprint: input.blueprint, + assets: input.assets, + topic: input.topic, + sellingPoints: input.sellingPoints ?? [], + durationSec: input.durationSec ?? base.timeline.durationSec, + directorPlan: base.directorPlan, + matches: base.matches, + gaps: base.gaps, + fills, + script, + storyboard, + timeline: base.timeline, + evidence, }); return MigrationPlan.parse({ @@ -113,6 +306,126 @@ export async function runExpertMigration( script, storyboard, fills, + directorPlan: base.directorPlan, + ...directorArtifacts, + evidence, + decisions: [ + ...base.decisions, + { + chosen: directorSource, + alternatives: ['llm', 'heuristic_fallback'], + confidence: directorSource === 'llm' ? 0.78 : 0.52, + reason: + directorSource === 'llm' + ? '先由 LLM DirectorAgent 设计剧情线和 directed shots,再交给 migration 执行匹配、缺口补全和 timeline。' + : 'LLM DirectorAgent 暂不可用或输出未通过 schema 校验,保留启发式 DirectorPlan 保障 P0 渲染链路。', + }, + ], rationale: expert.rationale || base.rationale, }); } + +function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { + return Math.max(aStart, bStart) < Math.min(aEnd, bEnd); +} + +function shotNeedsCopy(shot: { copyRequired?: boolean; copyMode?: string }): boolean { + return shot.copyRequired !== false && shot.copyMode !== 'none'; +} + +type ExpertShot = z.infer['shots'][number]; +type CopyFields = { + voiceoverScript: string; + screenText: string; + cardCopy: string; +}; + +function emptyCopyFields(): CopyFields { + return { voiceoverScript: '', screenText: '', cardCopy: '' }; +} + +function copyFieldsFrom(input: Partial & { copy?: string }): CopyFields { + return { + voiceoverScript: input.voiceoverScript?.trim() ?? '', + screenText: input.screenText?.trim() ?? '', + cardCopy: input.cardCopy?.trim() ?? '', + }; +} + +function copyFieldsForMode( + copyMode: string | undefined, + expert: ExpertShot | undefined, + fallback: CopyFields, + semanticContext: { + topic: string; + sellingPoints: string[]; + assetSummaries: string[]; + }, +): CopyFields { + const legacy = expert?.scriptText?.trim() ?? ''; + if (copyMode === 'voiceover') { + return { + ...emptyCopyFields(), + voiceoverScript: safeCopy(firstText(expert?.voiceoverScript, legacy, fallback.voiceoverScript), semanticContext), + }; + } + if (copyMode === 'title_card') { + return { + ...emptyCopyFields(), + cardCopy: safeCopy(firstText(expert?.cardCopy, expert?.screenText, legacy, fallback.cardCopy, fallback.screenText), semanticContext), + }; + } + return { + ...emptyCopyFields(), + screenText: safeCopy(firstText(expert?.screenText, legacy, fallback.screenText), semanticContext), + }; +} + +function safeCopy( + text: string, + semanticContext: { + topic: string; + sellingPoints: string[]; + assetSummaries: string[]; + }, +): string { + return sanitizeViewerCopy(text, semanticContext).text; +} + +function mergeCopyFields(fields: CopyFields[]): CopyFields { + return fields.reduce( + (acc, field) => ({ + voiceoverScript: joinCopy(acc.voiceoverScript, field.voiceoverScript), + screenText: joinCopy(acc.screenText, field.screenText), + cardCopy: joinCopy(acc.cardCopy, field.cardCopy), + }), + emptyCopyFields(), + ); +} + +function displayCopy(fields: CopyFields): string { + return fields.cardCopy || fields.screenText || fields.voiceoverScript || ''; +} + +function firstText(...values: Array): string { + return values.map((value) => value?.trim() ?? '').find(Boolean) ?? ''; +} + +function joinCopy(a: string, b: string): string { + const next = b.trim(); + if (!next) return a; + return a ? `${a} ${next}` : next; +} + +function stableTextCardSource(source: string, fillId: string): string { + if (!source.startsWith('textcard://')) return source; + return `textcard://expert-card-${hashId(fillId)}`; +} + +function hashId(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0; + } + return hash.toString(36); +} diff --git a/apps/api/src/agents/fillWithStock.ts b/apps/api/src/agents/fillWithStock.ts new file mode 100644 index 0000000..112ac90 --- /dev/null +++ b/apps/api/src/agents/fillWithStock.ts @@ -0,0 +1,68 @@ +import type { VideoStructureBlueprint } from '../core/blueprint'; +import type { Decision } from '../core/explain'; +import type { MigrationPlan } from '../core/migration'; +import { searchAndDownloadStock, type StockSearchFn } from './stockFootage'; + +export interface StockFillOptions { + /** 检索函数;默认走 Pexels(需 PEXELS_API_KEY,缺则回退)。可注入用于测试。 */ + search?: StockSearchFn; + /** 下载落地目录。 */ + outDir: string; + /** 用于按 slot 上的 segmentRole / requiredAssetTypes 构造检索词。 */ + blueprint?: VideoStructureBlueprint; + /** 自定义检索词构造;默认 topic + 资产类型英语关键词。 */ + buildQuery?: (ctx: { topic: string; role?: string; requiredAssetTypes?: string[] }) => string; +} + +// AssetTag → Pexels 检索的英文关键词(Pexels 中文检索效果差) +const ASSET_TERM: Record = { + talking_head: 'person talking', + product_closeup: 'product closeup', + usage_demo: 'using product', + comparison: 'comparison', + b_roll: 'cinematic b roll', + text_card: '', +}; + +function defaultBuildQuery(ctx: { topic: string; requiredAssetTypes?: string[] }): string { + const term = ctx.requiredAssetTypes?.map((t) => ASSET_TERM[t]).find(Boolean) ?? ''; + return [ctx.topic, term].filter(Boolean).join(' ').trim(); +} + +/** + * 用免费 stock 素材替换 text_card 缺口补全;检索失败/无 key → 保留 text_card。 + * 借鉴 OpenMontage「免费档案补全」的范式,自研薄适配器(非依赖)。 + */ +export async function enhanceFillsWithStock( + plan: MigrationPlan, + opts: StockFillOptions, +): Promise { + const search = opts.search ?? searchAndDownloadStock; + const buildQuery = opts.buildQuery ?? defaultBuildQuery; + const slots = opts.blueprint?.slots ?? []; + const decisions: Decision[] = [...plan.decisions]; + + const fills = await Promise.all( + plan.fills.map(async (f) => { + // text_card / copy_completion 都是「缺画面靠文案承接」状态,都尝试用 stock 真实素材替换 + if (f.kind !== 'text_card' && f.kind !== 'copy_completion') return f; + const slot = slots.find((s) => s.id === f.slotId); + const query = buildQuery({ + topic: plan.topic, + role: slot?.segmentRole, + requiredAssetTypes: slot?.requiredAssetTypes, + }); + const clip = await search(query, opts.outDir); + if (!clip) return f; + decisions.push({ + chosen: 'stock_clip', + alternatives: ['text_card'], + confidence: 0.7, + reason: `Pexels 检索命中:${clip.attribution}(query=${query})`, + }); + return { ...f, kind: 'stock_clip' as const, source: clip.path }; + }), + ); + + return { ...plan, fills, decisions }; +} diff --git a/apps/api/src/agents/sampleLearningAgent.ts b/apps/api/src/agents/sampleLearningAgent.ts new file mode 100644 index 0000000..a2242a5 --- /dev/null +++ b/apps/api/src/agents/sampleLearningAgent.ts @@ -0,0 +1,721 @@ +import { randomUUID } from 'node:crypto'; +import type { VideoStructureBlueprint } from '../core/blueprint'; +import type { SampleAnalysis } from '../core/sample'; +import { + SampleLearningDraft, + type EditingTechniquePattern, + type LearningQualityTags, + type LearnedSampleScope, + type RejectedLearningTechnique, +} from '../core/sampleLearning'; +import { createMusicFingerprintFromSample, createRhythmProfileFromSample } from '../core/rhythm'; +import { isActionableTemplateProfile } from '../core/template'; +import type { CardAnimationPreset, MotionPreset, TransitionPreset } from '../core/timeline'; + +export interface SampleLearningInput { + projectId?: string; + scope?: 'global' | 'project'; + learnScope?: Partial; + sample: { + id: string; + filename: string; + analysis: SampleAnalysis; + blueprint: VideoStructureBlueprint; + }; +} + +/** SampleLearningAgent:把已解析 / 已抽结构的样例沉淀成可编辑的 pattern-library 入库草案。 */ +export function runSampleLearningAgent(input: SampleLearningInput): SampleLearningDraft { + const { projectId, sample } = input; + const scope = input.scope ?? (projectId ? 'project' : 'global'); + const learnScope: LearnedSampleScope = { + storySkeleton: input.learnScope?.storySkeleton ?? true, + editingTechniques: input.learnScope?.editingTechniques ?? true, + packagingStyle: input.learnScope?.packagingStyle ?? true, + bgmSync: input.learnScope?.bgmSync ?? true, + }; + const { analysis, blueprint } = sample; + const now = new Date().toISOString(); + const name = `${sample.filename.replace(/\.[^.]+$/, '')} · ${genreName(blueprint.videoGenre)}模式`; + const segments = blueprint.scriptStructure.segments.map((segment) => ({ + role: segment.role, + label: segment.label, + durationRatio: segment.durationRatio, + intent: segment.intent, + copyPattern: segment.copyPattern, + watchingPurpose: purposeForRole(segment.role, segment.intent), + })); + const formula = segments + .map((segment) => segment.label || segment.copyPattern || segment.role) + .join(' -> '); + const reusablePatternName = `${genreName(blueprint.videoGenre)}:${segments[0]?.label ?? '开场'} -> ${segments.at(-1)?.label ?? '收束'}`; + const aspectRatio = `${analysis.metadata.width}:${analysis.metadata.height}`; + const summary = `该样例是 ${Math.round(analysis.metadata.durationSec)}s 的 ${genreName( + blueprint.videoGenre, + )} 视频,结构为 ${formula},节奏为 ${densityName(blueprint.rhythmStructure.cutDensity)}。`; + const transitionLearning = transitionDimensionFromBlueprint(blueprint, learnScope.editingTechniques); + const editingTechniques = learnScope.editingTechniques ? editingTechniquesFromTransitionLearning(transitionLearning.dimension.executableTechniques) : []; + const packagingPattern = learnScope.packagingStyle ? packagingPatternFromBlueprint(blueprint) : undefined; + const templateProfile = analysis.templateProfile && (learnScope.editingTechniques || learnScope.packagingStyle) + ? { ...analysis.templateProfile, source: 'global_sample' as const } + : undefined; + const bgmSyncPattern = learnScope.bgmSync ? bgmSyncPatternFromBlueprint(analysis, blueprint) : undefined; + const musicFingerprint = learnScope.bgmSync ? createMusicFingerprintFromSample(analysis, blueprint) : undefined; + const rhythmProfile = learnScope.bgmSync + ? createRhythmProfileFromSample({ + sampleId: sample.id, + analysis, + blueprint, + source: 'global_sample', + }) + : undefined; + const qualityTags = qualityTagsForSample(analysis, blueprint, formula); + const storySkeleton = learnScope.storySkeleton && qualityTags.recommendedUse !== 'editing_only' && qualityTags.recommendedUse !== 'template_only' + ? storySkeletonFromBlueprint(blueprint) + : undefined; + const recommendation = recommendLearnMode(analysis, blueprint, editingTechniques.length, qualityTags); + const visualPackaging = visualPackagingFromBlueprint(blueprint); + const learnedDimensions = { + scriptStructure: { + formula, + segmentCount: segments.length, + segments, + notes: [ + `脚本结构由 ${segments.length} 个段落组成,按 ${segments.map((segment) => segment.role).join(' -> ')} 推进。`, + ], + }, + shotRhythm: { + durationSec: analysis.metadata.durationSec, + shotCount: analysis.shotCount, + avgShotSec: blueprint.rhythmStructure.avgShotSec, + cutDensity: blueprint.rhythmStructure.cutDensity, + peakAt: blueprint.rhythmStructure.peakAt, + beatHints: blueprint.rhythmStructure.bgmBeatHints, + rhythmNotes: [ + `平均 ${blueprint.rhythmStructure.avgShotSec.toFixed(1)}s/镜,整体为 ${densityName( + blueprint.rhythmStructure.cutDensity, + )}。`, + `高潮位置约在全片 ${(blueprint.rhythmStructure.peakAt * 100).toFixed(0)}%。`, + ], + }, + subtitleStyle: subtitleStyleFromBlueprint(blueprint), + visualPackaging: { + ...visualPackaging, + notes: [ + ...visualPackaging.notes, + ...(templateProfile + ? [ + `模板画幅:${templateProfile.layoutPreset},viewport=${templateProfile.frameStyle.viewport?.aspectRatio ?? templateProfile.sourceAspect},Remotion 渲染可复用。`, + ] + : []), + ], + }, + transitions: transitionLearning.dimension, + bgmSync: bgmSyncFromBlueprint(analysis, blueprint), + }; + + const dbData = { + id: `pattern_${randomUUID().slice(0, 8)}`, + scope, + projectId, + sourceSampleId: sample.id, + name, + summary, + videoGenre: blueprint.videoGenre, + tags: [ + blueprint.videoGenre, + blueprint.rhythmStructure.cutDensity, + ...segments.map((segment) => segment.role), + ], + qualityTags, + learnScope, + reusablePatternName, + formula, + source: { + filename: sample.filename, + sourcePath: analysis.sourcePath, + durationSec: analysis.metadata.durationSec, + aspectRatio, + shotCount: analysis.shotCount, + }, + segments, + pacing: { + durationSec: analysis.metadata.durationSec, + shotCount: analysis.shotCount, + avgShotSec: blueprint.rhythmStructure.avgShotSec, + cutDensity: blueprint.rhythmStructure.cutDensity, + peakAt: blueprint.rhythmStructure.peakAt, + beatHints: blueprint.rhythmStructure.bgmBeatHints, + }, + packaging: blueprint.packagingStructure, + storySkeleton, + editingTechniques, + packagingPattern, + bgmSyncPattern, + musicFingerprint, + rhythmProfile, + templateProfile, + learnedDimensions, + slotNeeds: blueprint.slots.map((slot) => ({ + slotId: slot.id, + segmentRole: slot.segmentRole, + requiredAssetTypes: slot.requiredAssetTypes, + minDurationSec: slot.minDurationSec, + optional: slot.optional, + })), + evidence: blueprint.evidence, + rationale: blueprint.rationale, + createdAt: now, + updatedAt: now, + }; + + return SampleLearningDraft.parse({ + id: `draft_${randomUUID().slice(0, 8)}`, + scope, + projectId, + sampleId: sample.id, + status: 'draft', + learnedThings: { + summary, + reusablePatternName, + formula, + keyTakeaways: [ + ...(learnScope.storySkeleton ? [`脚本结构:${formula}`] : []), + `镜头节奏:平均 ${blueprint.rhythmStructure.avgShotSec.toFixed(1)}s/镜,${densityName( + blueprint.rhythmStructure.cutDensity, + )}`, + ...(learnScope.packagingStyle ? [`字幕样式:${learnedDimensions.subtitleStyle.density},${learnedDimensions.subtitleStyle.placement}`] : []), + ...(learnScope.packagingStyle ? [`画面包装:${learnedDimensions.visualPackaging.titleBarStyle ?? '无明显标题条'} / ${learnedDimensions.visualPackaging.stickerUsage ?? '无明显贴纸'}`] : []), + ...(learnScope.editingTechniques + ? [editingLanguageTakeaway(analysis, blueprint, learnedDimensions.transitions.executableTechniques.length)] + : []), + ...(learnScope.bgmSync ? [`BGM 卡点:${learnedDimensions.bgmSync.syncStrategy}`] : []), + ...(rhythmProfile ? [`节奏迁移:记录 ${rhythmProfile.events.length} 个 cut/title/caption 与 beat/phrase 的相对关系`] : []), + ...(templateProfile + ? [ + `模板剪辑:${templateProfile.layoutPreset} / ${templateProfile.motionLanguage.internalMotionIntensity} 内部运动 / ${templateProfile.audioOnsets.length} 个真实音频 onset`, + ] + : []), + `入库质检:${qualityTags.recommendedUse} / ${qualityTags.patternDepth} / CTA=${qualityTags.ctaType}`, + `素材需求:${blueprint.slots.length} 个可复用槽位`, + ], + storageNotes: [ + '入库数据保存结构模式、节奏、包装、槽位需求和 evidence,不保存用户媒体本体。', + 'BGM 不同时会用 musicFingerprint 检索相似音乐样例,再用 rhythmProfile 的 beat-index 事件映射到目标 BGM。', + '模板化样例会额外保存 templateProfile,迁移时由 Remotion 复用画幅框、内部运动和遮罩/onset 锚点。', + '剪辑技巧只有能映射到当前 Remotion / FFmpeg preset 时才写入 dbData;无法复现的效果仅在 rejectedTechniques 提示用户,不进入全局库。', + '后续迁移可按 videoGenre、tags、formula 或 slotNeeds 检索该 pattern。', + ], + risks: [ + ...(analysis.metadata.hasAudio + ? ['ASR / beat detect 未完成前,音频卡点仍只能作为粗略提示。'] + : ['样例无音轨,不能学习真实声音节奏。']), + ...qualityTags.warnings, + ], + rejectedTechniques: transitionLearning.rejected, + recommendation, + }, + dbData, + }); +} + +function qualityTagsForSample( + analysis: SampleAnalysis, + blueprint: VideoStructureBlueprint, + formula: string, +): LearningQualityTags { + const text = searchablePatternText(blueprint, formula); + const ctaType = inferCtaType(text, blueprint); + const patternDepth = inferPatternDepth(analysis, blueprint); + const visualBridgePolicy = inferVisualBridgePolicy(text, blueprint, patternDepth); + const commercialUsefulness = inferCommercialUsefulness(blueprint, ctaType, patternDepth); + const recommendedUse = inferRecommendedUse(patternDepth, commercialUsefulness, blueprint, analysis); + const warnings = [ + ...(patternDepth === 'template_or_editing_only' + ? ['这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。'] + : []), + ...(patternDepth === 'thin_pattern' + ? ['这条样例故事结构偏薄,建议作为 secondary story 或剪辑参考。'] + : []), + ...(ctaType === 'platform_follow' || ctaType === 'search_account' || ctaType === 'tutorial_get' + ? ['检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。'] + : []), + ...(commercialUsefulness === 'weak' || commercialUsefulness === 'not_recommended' + ? ['商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。'] + : []), + ...(visualBridgePolicy.use !== 'allowed' + ? [`视觉桥接限制:${visualBridgePolicy.reasons.join(';') || '默认只学习方法,不复用画面。'}`] + : []), + ]; + return { + patternDepth, + ctaType, + visualBridgePolicy, + commercialUsefulness, + recommendedUse, + warnings, + }; +} + +function searchablePatternText(blueprint: VideoStructureBlueprint, formula: string): string { + return [ + formula, + blueprint.videoGenre, + ...blueprint.scriptStructure.segments.flatMap((segment) => [ + segment.label, + segment.intent, + segment.copyPattern, + segment.visualRole, + segment.captionStyle?.notes, + ]), + blueprint.packagingStructure?.titleBarStyle, + blueprint.packagingStructure?.stickerUsage, + blueprint.packagingStructure?.transitionStyle, + blueprint.packagingStructure?.coverStyle, + ...blueprint.slots.flatMap((slot) => slot.requiredAssetTypes), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); +} + +function inferPatternDepth(analysis: SampleAnalysis, blueprint: VideoStructureBlueprint): LearningQualityTags['patternDepth'] { + const segmentCount = blueprint.scriptStructure.segments.length; + const hasHook = blueprint.scriptStructure.segments.some((segment) => segment.role === 'hook'); + const hasClosing = blueprint.scriptStructure.segments.some((segment) => segment.role === 'closing'); + const durationSec = analysis.metadata.durationSec; + if (analysis.shotCount <= 2 || durationSec < 12) return 'template_or_editing_only'; + if (segmentCount < 3 || !hasHook || !hasClosing || analysis.shotCount < 5 || durationSec < 15) { + return 'thin_pattern'; + } + if (durationSec <= 45 && analysis.shotCount >= 8 && segmentCount >= 4) return 'full_story'; + return 'story_candidate'; +} + +function inferCtaType(text: string, blueprint: VideoStructureBlueprint): LearningQualityTags['ctaType'] { + const closing = blueprint.scriptStructure.segments.find((segment) => segment.role === 'closing'); + const hasClosing = Boolean(closing); + if (/(关注|粉丝|账号|主页|平台|搜索|搜一搜|同款|获取教程|教程获取|领取教程|私信|引流|follow|subscribe|account)/i.test(text)) { + return 'platform_follow'; + } + if (/(教程|get tutorial|tutorial)/i.test(text)) return 'tutorial_get'; + if (/(预约|到店|订座|预订|booking|reserve|book now)/i.test(text)) return 'booking'; + if (/(试用|免费体验|开始体验|立即体验|trial|try now|start free)/i.test(text)) return 'trial'; + if (/(咨询|留资|表单|加微信|私域|lead|contact us)/i.test(text)) return 'lead_capture'; + if (/(购买|下单|领券|优惠|报价|限时|加入购物车|buy|purchase|shop now|order)/i.test(text)) return 'purchase'; + return hasClosing ? 'generic_next_action' : 'none'; +} + +function inferVisualBridgePolicy( + text: string, + blueprint: VideoStructureBlueprint, + patternDepth: LearningQualityTags['patternDepth'], +): LearningQualityTags['visualBridgePolicy'] { + const reasons: string[] = []; + const slotTags = blueprint.slots.flatMap((slot) => slot.requiredAssetTypes); + if (slotTags.includes('talking_head') || /(真人|人物|人像|游客|自拍|出镜|口播|脸|女孩|男孩|路人|互动|person|people|face|selfie|tourist)/i.test(text)) { + reasons.push('包含真人 / 人像 / 游客 / 口播风险画面'); + } + if (/(水印|平台|账号|用户名|搜索|关注|logo|标识|官方|教程获取|获取教程|watermark)/i.test(text)) { + reasons.push('包含平台水印 / 账号标识 / 搜索引导'); + } + if (slotTags.some((tag) => tag === 'product_closeup' || tag === 'usage_demo' || tag === 'comparison')) { + reasons.push('包含产品、动作细节或结果证明类画面,不应用参考画面伪造'); + } + if (reasons.length) return { use: 'blocked', reasons }; + if (patternDepth === 'template_or_editing_only') { + return { use: 'learn_only', reasons: ['样例过薄,默认只学习模板 / 剪辑语言;如需桥接应由用户显式确认。'] }; + } + return { use: 'allowed', reasons: ['未检测到人物、水印、平台账号或事实证明风险,可作为短氛围 / 尺度 / 转场桥接候选。'] }; +} + +function inferCommercialUsefulness( + blueprint: VideoStructureBlueprint, + ctaType: LearningQualityTags['ctaType'], + patternDepth: LearningQualityTags['patternDepth'], +): LearningQualityTags['commercialUsefulness'] { + const slotTags = new Set(blueprint.slots.flatMap((slot) => slot.requiredAssetTypes)); + const hasProofVisual = slotTags.has('comparison') || slotTags.has('usage_demo'); + const hasProductVisual = slotTags.has('product_closeup') || hasProofVisual; + const conversionCta = ctaType === 'purchase' || ctaType === 'booking' || ctaType === 'trial' || ctaType === 'lead_capture'; + if (patternDepth === 'template_or_editing_only' && (ctaType === 'platform_follow' || ctaType === 'search_account' || ctaType === 'tutorial_get')) { + return 'not_recommended'; + } + if (conversionCta && (blueprint.videoGenre === 'product' || hasProductVisual || blueprint.videoGenre === 'tutorial')) { + return 'strong'; + } + if (blueprint.videoGenre === 'product' || hasProofVisual) return 'medium'; + if (ctaType === 'platform_follow' || ctaType === 'search_account' || ctaType === 'tutorial_get') return 'weak'; + if (blueprint.videoGenre === 'showcase' || blueprint.videoGenre === 'vlog') return 'weak'; + return 'medium'; +} + +function inferRecommendedUse( + patternDepth: LearningQualityTags['patternDepth'], + commercialUsefulness: LearningQualityTags['commercialUsefulness'], + blueprint: VideoStructureBlueprint, + analysis: SampleAnalysis, +): LearningQualityTags['recommendedUse'] { + if (patternDepth === 'template_or_editing_only') { + return isActionableTemplateProfile(analysis.templateProfile) ? 'template_only' : 'editing_only'; + } + if (commercialUsefulness === 'not_recommended') return 'learn_only'; + if (patternDepth === 'thin_pattern') return 'secondary_story'; + if (patternDepth === 'full_story' && (commercialUsefulness === 'strong' || blueprint.videoGenre === 'product')) { + return 'primary_story'; + } + return 'secondary_story'; +} + +function genreName(genre: string): string { + const map: Record = { + narrative: '叙事', + tutorial: '教程', + vlog: 'Vlog', + commentary: '解说', + showcase: '展示', + product: '带货', + other: '通用', + }; + return map[genre] ?? genre; +} + +function densityName(density: string): string { + const map: Record = { + low: '慢节奏', + medium: '中等节奏', + high: '快节奏', + }; + return map[density] ?? density; +} + +function purposeForRole(role: string, intent: string): string { + const map: Record = { + hook: `开场抓停:${intent}`, + setup: `建立背景:${intent}`, + develop: `推进主体:${intent}`, + climax: `放大重点:${intent}`, + closing: `收束记忆点:${intent}`, + }; + return map[role] ?? intent; +} + +function subtitleStyleFromBlueprint(blueprint: VideoStructureBlueprint) { + const packaging = blueprint.packagingStructure; + const density = packaging?.subtitleDensity ?? 'unknown'; + return { + density, + placement: density === 'sparse' ? '少量关键句上屏' : density === 'dense' ? '高频字幕跟随信息点' : '中等密度字幕', + typography: packaging?.titleBarStyle ? `与标题条风格「${packaging.titleBarStyle}」协同` : '未识别明确字体样式', + animation: packaging?.transitionStyle ? `字幕/标题可能配合「${packaging.transitionStyle}」出现` : '未识别明确字幕动画', + notes: ['当前来自结构蓝图 packagingStructure 的抽象总结,未做 OCR 字体级检测。'], + }; +} + +function visualPackagingFromBlueprint(blueprint: VideoStructureBlueprint) { + const packaging = blueprint.packagingStructure; + return { + titleBarStyle: packaging?.titleBarStyle, + stickerUsage: packaging?.stickerUsage, + coverStyle: packaging?.coverStyle, + overlayStyle: [packaging?.titleBarStyle, packaging?.stickerUsage].filter(Boolean).join(' / ') || 'unknown', + notes: packaging + ? [`画面包装迁移重点:${[packaging.titleBarStyle, packaging.stickerUsage, packaging.coverStyle].filter(Boolean).join(';')}`] + : ['蓝图未识别明确画面包装。'], + }; +} + +function storySkeletonFromBlueprint(blueprint: VideoStructureBlueprint) { + const segments = blueprint.scriptStructure.segments; + const roles = segments.map((segment) => segment.role); + const functions = storyFunctionsForRoles(roles); + return { + arcType: `${genreName(blueprint.videoGenre)} / ${roles.join(' -> ')}`, + segmentRoles: roles, + emotionalCurve: segments.map((segment) => segment.intent), + hookStyle: segments[0]?.copyPattern ?? 'unknown', + turnOrProofStyle: segments.find((segment) => segment.role === 'climax' || segment.role === 'develop')?.copyPattern ?? 'unknown', + payoffStyle: segments.at(-1)?.copyPattern ?? 'unknown', + requiredStoryFunctions: functions, + bestForGenres: [blueprint.videoGenre], + assetRequirements: [...new Set(blueprint.slots.flatMap((slot) => slot.requiredAssetTypes))], + }; +} + +function storyFunctionsForRoles(roles: string[]) { + const mapped = roles.flatMap((role) => { + if (role === 'hook') return ['opening_hook'] as const; + if (role === 'setup') return ['context'] as const; + if (role === 'develop') return ['action', 'detail'] as const; + if (role === 'climax') return ['turn', 'proof'] as const; + if (role === 'closing') return ['payoff', 'cta'] as const; + return ['transition'] as const; + }); + return [...new Set(mapped)]; +} + +function packagingPatternFromBlueprint(blueprint: VideoStructureBlueprint) { + const visual = visualPackagingFromBlueprint(blueprint); + return { + ...visual, + cardAnimationPreset: executableCardAnimation(blueprint.packagingStructure?.transitionStyle), + implementationNotes: '包装样式已约束为当前 card style / card animation preset 可表达的范围;复杂贴纸跟踪不在 P0 入库。', + }; +} + +function bgmSyncPatternFromBlueprint(analysis: SampleAnalysis, blueprint: VideoStructureBlueprint) { + const bgm = bgmSyncFromBlueprint(analysis, blueprint); + return { + beatPlacement: blueprint.rhythmStructure.cutDensity === 'high' ? 'visual cuts align to dense beat grid' : 'major transitions align to phrase beats', + syncStrategy: bgm.syncStrategy, + confidence: bgm.confidence, + limitations: bgm.limitations, + }; +} + +function recommendLearnMode( + analysis: SampleAnalysis, + blueprint: VideoStructureBlueprint, + executableTechniqueCount: number, + qualityTags: LearningQualityTags, +): { suggestedMode: 'storySkeleton' | 'editingTechniques' | 'both'; reasons: string[] } { + if (qualityTags.recommendedUse === 'editing_only' || qualityTags.recommendedUse === 'template_only') { + return { + suggestedMode: 'editingTechniques', + reasons: [ + '入库质检判断这条更适合学剪辑 / 模板,不适合作为主故事骨架。', + ...qualityTags.warnings, + ], + }; + } + if (qualityTags.recommendedUse === 'learn_only') { + return { + suggestedMode: 'editingTechniques', + reasons: [ + '入库质检判断这条只适合学习方法,不建议作为主骨架或视觉桥接素材。', + ...qualityTags.warnings, + ], + }; + } + const segmentCount = blueprint.scriptStructure.segments.length; + const hasHookAndPayoff = + blueprint.scriptStructure.segments[0]?.role === 'hook' && + blueprint.scriptStructure.segments.at(-1)?.role === 'closing'; + const storyStrong = segmentCount >= 3 && hasHookAndPayoff; + const editStrong = + executableTechniqueCount > 0 && + (blueprint.rhythmStructure.cutDensity === 'high' || + analysis.shotCount >= 8 || + Boolean(blueprint.packagingStructure?.transitionStyle && blueprint.packagingStructure.transitionStyle !== 'unknown')); + const reasons = [ + ...(storyStrong ? ['段落清晰,hook / payoff 明显,适合学习视频骨架。'] : []), + ...(editStrong ? ['存在可映射到 Remotion / FFmpeg preset 的节奏或转场,适合学习剪辑策略。'] : []), + ...(!storyStrong && !editStrong ? ['当前样例结构和可执行剪辑信号都偏弱,建议先只学习骨架并人工校正。'] : []), + ]; + return { + suggestedMode: storyStrong && editStrong ? 'both' : editStrong ? 'editingTechniques' : 'storySkeleton', + reasons, + }; +} + +function transitionDimensionFromBlueprint(blueprint: VideoStructureBlueprint, shouldLearn: boolean) { + const transitionStyle = blueprint.packagingStructure?.transitionStyle ?? 'unknown'; + const cutDensity = blueprint.rhythmStructure.cutDensity; + const executableTechniques = shouldLearn ? executableTechniquesFromBlueprint(blueprint) : []; + const rejected = shouldLearn ? rejectedTechniquesFromBlueprint(blueprint, executableTechniques.length) : []; + return { + dimension: { + style: transitionStyle, + frequency: cutDensity === 'high' ? '高频切换' : cutDensity === 'medium' ? '中等频率切换' : '低频切换', + notableTransitions: transitionStyle === 'unknown' ? [] : [transitionStyle], + executableTechniques, + }, + rejected, + }; +} + +function editingLanguageTakeaway( + analysis: SampleAnalysis, + blueprint: VideoStructureBlueprint, + executableTechniqueCount: number, +): string { + const transitionStyle = blueprint.packagingStructure?.transitionStyle ?? 'unknown'; + const rhythmShots = blueprint.rhythmStructure.shots ?? []; + const motionHints = [ + ...blueprint.scriptStructure.segments.map((segment) => segment.motionIntent), + ...rhythmShots.map((shot) => shot.motionIntent), + ].filter(Boolean); + const uniqueMotionHints = [...new Set(motionHints)]; + const motionText = uniqueMotionHints.length + ? `镜头内运动可参考 ${uniqueMotionHints.join(' / ')}` + : '镜头内运动以主体动作和轻微 push/pan 承接'; + + if (analysis.shotCount <= 1 && isActionableTemplateProfile(analysis.templateProfile)) { + const profile = analysis.templateProfile; + return [ + `剪辑语言:未检测到硬切,但识别出 ${profile.layoutPreset} 模板`, + `内部运动 ${profile.motionLanguage.internalMotionIntensity},模板事件 ${profile.events.length} 个`, + `迁移时保留画幅框 / 遮罩运动,并按 ${profile.audioOnsets.length} 个真实音频 onset 做视觉变化锚点`, + ].join(';'); + } + + if (analysis.shotCount <= 1) { + return [ + `剪辑语言:单镜头连续呈现,未检测到镜头间转场`, + `${motionText}`, + `可执行技巧 ${executableTechniqueCount} 个;迁移时保持稳定长镜头感,少用强转场`, + ].join(';'); + } + + return [ + `剪辑语言:${analysis.shotCount} 个镜头 / ${analysis.scenes.length} 个切点`, + `转场偏好:${transitionStyle}`, + `可执行技巧 ${executableTechniqueCount} 个`, + ].join(';'); +} + +function executableTechniquesFromBlueprint(blueprint: VideoStructureBlueprint) { + const techniques: Array<{ + id: string; + name: string; + triggerCondition: string; + appliesToStoryFunctions: Array<'opening_hook' | 'context' | 'detail' | 'proof' | 'payoff' | 'transition' | 'cta'>; + requiredRenderer: 'remotion' | 'ffmpeg' | 'both'; + motionPreset?: MotionPreset; + transitionPreset?: TransitionPreset; + cardAnimationPreset?: CardAnimationPreset; + implementationNotes: string; + }> = []; + const density = blueprint.rhythmStructure.cutDensity; + const transition = executableTransition(blueprint.packagingStructure?.transitionStyle); + if (transition) { + techniques.push({ + id: `tech_transition_${transition}`, + name: `可执行转场:${transition}`, + triggerCondition: `cutDensity=${density} 且 shot 边界需要承接情绪或信息切换`, + appliesToStoryFunctions: ['context', 'detail', 'proof', 'payoff', 'transition'], + requiredRenderer: 'both', + transitionPreset: transition, + implementationNotes: `已映射到 Timeline.transitionPreset=${transition},Remotion / FFmpeg fallback 均可执行。`, + }); + } + const motion = density === 'high' ? 'beat_pulse' : density === 'medium' ? 'parallax_drift' : 'pan_left'; + techniques.push({ + id: `tech_image_${motion}`, + name: `图片素材运镜:${motion}`, + triggerCondition: `image asset 用于 context/detail/payoff,且需要避免静态相册感`, + appliesToStoryFunctions: ['context', 'detail', 'payoff'], + requiredRenderer: 'both', + motionPreset: motion, + implementationNotes: `已映射到 MotionPreset=${motion},Remotion 执行 transform,FFmpeg fallback 执行 zoompan。`, + }); + const cardAnimation = executableCardAnimation(blueprint.packagingStructure?.transitionStyle); + if (cardAnimation) { + techniques.push({ + id: `tech_card_${cardAnimation}`, + name: `文字卡进出:${cardAnimation}`, + triggerCondition: 'text_card / packaging_overlay 用于 hook、转场或 CTA', + appliesToStoryFunctions: ['opening_hook', 'transition', 'cta'], + requiredRenderer: 'remotion', + cardAnimationPreset: cardAnimation, + implementationNotes: `已映射到 Timeline.cardAnimationPreset=${cardAnimation},Remotion 可执行,FFmpeg fallback 会尽量用 xfade 降级。`, + }); + } + return techniques; +} + +function editingTechniquesFromTransitionLearning( + techniques: Array<{ + id: string; + name: string; + triggerCondition: string; + appliesToStoryFunctions: EditingTechniquePattern['appliesToStoryFunction']; + requiredRenderer: EditingTechniquePattern['requiredRenderer']; + motionPreset?: MotionPreset; + transitionPreset?: TransitionPreset; + cardAnimationPreset?: CardAnimationPreset; + implementationNotes: string; + }>, +): EditingTechniquePattern[] { + return techniques.map((technique) => ({ + id: technique.id, + name: technique.name, + triggerCondition: technique.triggerCondition, + appliesToStoryFunction: technique.appliesToStoryFunctions, + appliesToAssetType: technique.motionPreset ? ['image'] : technique.cardAnimationPreset ? ['text'] : ['video', 'image'], + appliesToVisualCluster: [], + motionPreset: technique.motionPreset, + transitionPreset: technique.transitionPreset, + beatPlacement: technique.motionPreset === 'beat_pulse' ? 'on estimated beat grid' : 'phrase boundary or shot boundary', + cardAnimationPreset: technique.cardAnimationPreset, + intensity: technique.motionPreset === 'beat_pulse' || technique.transitionPreset === 'snap_cut' ? 'high' : 'medium', + avoidWhen: technique.motionPreset ? ['dialogue_heavy', 'proof_requires_detail'] : ['shot_requires_no_distraction'], + requiredRenderer: technique.requiredRenderer, + implementationNotes: technique.implementationNotes, + })); +} + +function executableTransition(style: string | undefined): TransitionPreset | undefined { + const text = (style ?? '').toLowerCase(); + if (!text || text === 'unknown') return undefined; + if (/whip|甩|横移|hblur/.test(text)) return 'whip_cut'; + if (/snap|弹|快切|闪/.test(text)) return 'snap_cut'; + if (/fade|叠|溶|柔|淡/.test(text)) return 'crossfade'; + if (/cut|硬切|直切/.test(text)) return 'cut'; + return undefined; +} + +function executableCardAnimation(style: string | undefined): CardAnimationPreset | undefined { + const text = (style ?? '').toLowerCase(); + if (/弹|snap|pop/.test(text)) return 'snap_pop'; + if (/滑|slide|横移/.test(text)) return 'slide_left'; + if (/wipe|上移|揭示/.test(text)) return 'wipe_up'; + if (/柔|淡|fade|叠/.test(text)) return 'soft_crossfade'; + return undefined; +} + +function rejectedTechniquesFromBlueprint( + blueprint: VideoStructureBlueprint, + executableCount: number, +): RejectedLearningTechnique[] { + const text = [ + blueprint.packagingStructure?.transitionStyle, + blueprint.packagingStructure?.stickerUsage, + blueprint.packagingStructure?.titleBarStyle, + blueprint.packagingStructure?.coverStyle, + ].filter(Boolean).join(' '); + const rejected: RejectedLearningTechnique[] = []; + if (/(3d|三维|粒子|抠像|绿幕|形变|液态|复杂蒙版|跟踪|tracking|particle|mask|morph|speed ramp|变速)/i.test(text)) { + rejected.push({ + name: '复杂视觉特效 / 高级跟踪类剪辑', + reason: `样例疑似包含当前 preset 无法稳定复现的效果:${text}`, + userMessage: '该剪辑效果暂时不能由当前 Remotion / FFmpeg preset 稳定复现,所以不会写入全局可复用技巧库。', + }); + } + if (executableCount === 0 && text.trim()) { + rejected.push({ + name: '未映射转场/包装描述', + reason: `无法把「${text}」映射到现有 motion/transition/card preset。`, + userMessage: '这个样例的剪辑描述还没有对应实现,建议先只学习视频骨架,或等实现对应 preset 后再入库剪辑技巧。', + }); + } + return rejected; +} + +function bgmSyncFromBlueprint(analysis: SampleAnalysis, blueprint: VideoStructureBlueprint) { + const beatHints = blueprint.rhythmStructure.bgmBeatHints; + const hasAudio = analysis.metadata.hasAudio; + return { + hasAudio, + beatHints, + syncStrategy: hasAudio + ? beatHints.length + ? `参考蓝图中的 ${beatHints.length} 条 BGM / 节奏卡点提示` + : '样例有音轨,但当前未实现精确 beat detect,仅保留粗略节奏提示' + : '样例无音轨,不能学习真实 BGM 卡点', + confidence: hasAudio ? (beatHints.length ? 'medium' : 'low') : 'none', + limitations: hasAudio && beatHints.length + ? ['BGM 卡点来自结构推断,未做精确音频 beat detect。'] + : ['没有实现 detect_beats 或等价音频分析前,不宣称精确 BGM 卡点。'], + }; +} diff --git a/apps/api/src/agents/scripts/case2-demo.ts b/apps/api/src/agents/scripts/case2-demo.ts index 7e8f2c9..6e0f00f 100644 --- a/apps/api/src/agents/scripts/case2-demo.ts +++ b/apps/api/src/agents/scripts/case2-demo.ts @@ -52,7 +52,17 @@ console.log('校验:', validateMigrationPlan(plan)); const resolveAsset = (src: TimelineSource): string | null => { if (src.kind === 'user_asset') return pathById.get(src.assetId) ?? null; - if (src.kind === 'fill_artifact') return plan.fills.find((f) => f.id === src.fillArtifactId)?.source ?? null; + if (src.kind === 'fill_artifact') { + const fill = plan.fills.find((f) => f.id === src.fillArtifactId); + const fillSource = fill?.source; + if (fillSource?.startsWith('asset://')) { + return pathById.get(decodeURIComponent(fillSource.slice('asset://'.length))) ?? null; + } + if (fillSource?.startsWith('textcard://')) { + return `textcard://${encodeURIComponent(fill?.displayText ?? fillSource.slice('textcard://'.length))}`; + } + return fillSource ?? null; + } return src.path; }; diff --git a/apps/api/src/agents/scripts/structure-demo.ts b/apps/api/src/agents/scripts/structure-demo.ts index 06268bb..cb6afd1 100644 --- a/apps/api/src/agents/scripts/structure-demo.ts +++ b/apps/api/src/agents/scripts/structure-demo.ts @@ -10,6 +10,7 @@ const analysis: SampleAnalysis = { metadata: { durationSec: 32, width: 1080, height: 1920, fps: 30, videoCodec: 'h264', audioCodec: 'aac', hasAudio: true }, scenes: [3, 6, 10, 14, 18, 22, 26, 29].map((t, i) => ({ index: i + 1, atSec: t })), shotCount: 9, + transcriptCues: [], keyframes: [], coverPath: '(n/a)', evidence: [ diff --git a/apps/api/src/agents/scripts/versions-demo.ts b/apps/api/src/agents/scripts/versions-demo.ts new file mode 100644 index 0000000..0eb8c05 --- /dev/null +++ b/apps/api/src/agents/scripts/versions-demo.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { VideoStructureBlueprint } from '../../core/blueprint'; +import type { TaggedAsset } from '../../core/slot'; +import { generateVersions } from '../../core/versions'; + +// 多版本演示(规则版,无需 key):用 sample1 蓝图套预设变换。 +const blueprint = JSON.parse( + readFileSync(resolve('out/analysis/sample1/blueprint.json'), 'utf8'), +) as VideoStructureBlueprint; + +const assets: TaggedAsset[] = [ + { id: 'a', mediaType: 'video', assetTags: ['talking_head'], durationSec: 12, confidence: 0.8, summary: '口播' }, + { id: 'b', mediaType: 'video', assetTags: ['b_roll'], durationSec: 20, confidence: 0.8, summary: '空镜' }, +]; + +const variants = generateVersions({ + projectId: 'demo', + sampleId: blueprint.sourceSampleId, + blueprint, + assets, + topic: 'F1 赛车手追逐冠军梦想的赛季故事', + sellingPoints: ['全力以赴争取每一分'], + durationSec: 40, +}); + +console.log(`base: cutDensity=${blueprint.rhythmStructure.cutDensity} avgShot=${blueprint.rhythmStructure.avgShotSec} peakAt=${blueprint.rhythmStructure.peakAt}`); +for (const v of variants) { + const r = v.blueprint.rhythmStructure; + const hook = v.blueprint.scriptStructure.segments.find((s) => s.role === 'hook'); + console.log( + `\n[${v.label}] ${v.describe}\n cutDensity=${r.cutDensity} avgShot=${r.avgShotSec} peakAt=${r.peakAt} hook%=${hook ? Math.round(hook.durationRatio * 100) : '-'} gaps=${v.migration.gaps.length}`, + ); +} diff --git a/apps/api/src/agents/stockFootage.ts b/apps/api/src/agents/stockFootage.ts new file mode 100644 index 0000000..31dc33d --- /dev/null +++ b/apps/api/src/agents/stockFootage.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto'; +import { createWriteStream, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import type { ReadableStream as WebReadable } from 'node:stream/web'; +import { getPexelsKey } from '../config'; + +export interface StockClip { + /** 下载到本地的文件绝对路径(供 render 使用)。 */ + path: string; + /** 归属信息(供 UI / 答辩展示,遵守 Pexels 署名要求)。 */ + attribution: string; + sourceUrl: string; +} + +export type StockSearchFn = (query: string, outDir: string) => Promise; + +interface PexelsFile { + link: string; + width: number; + height: number; + file_type: string; +} +interface PexelsVideo { + url: string; + user?: { name?: string }; + video_files?: PexelsFile[]; +} + +const PEXELS_VIDEO_SEARCH = 'https://api.pexels.com/videos/search'; + +/** 选一个合适分辨率的 mp4 文件(尽量靠近 720p,避免超大)。 */ +function pickFile(files: PexelsFile[]): PexelsFile | null { + const mp4 = files.filter((f) => f.file_type === 'video/mp4'); + if (mp4.length === 0) return null; + return mp4.slice().sort((a, b) => (a.height - 720) ** 2 - (b.height - 720) ** 2)[0]; +} + +/** 默认实现:Pexels 视频检索 + 下载。无 key / 失败 → null(调用方回退 text_card)。 */ +export const searchAndDownloadStock: StockSearchFn = async (query, outDir) => { + const key = getPexelsKey(); + if (!key || !query.trim()) return null; + try { + const url = `${PEXELS_VIDEO_SEARCH}?query=${encodeURIComponent(query)}&per_page=1`; + const res = await fetch(url, { headers: { Authorization: key } }); + if (!res.ok) return null; + const data = (await res.json()) as { videos?: PexelsVideo[] }; + const video = data.videos?.[0]; + const file = pickFile(video?.video_files ?? []); + if (!video || !file) return null; + + mkdirSync(outDir, { recursive: true }); + const dest = join(outDir, `stock_${randomUUID().slice(0, 8)}.mp4`); + const dl = await fetch(file.link); + if (!dl.ok || !dl.body) return null; + await pipeline(Readable.fromWeb(dl.body as WebReadable), createWriteStream(dest)); + const by = video.user?.name ? ` by ${video.user.name}` : ''; + return { path: dest, attribution: `Pexels${by}`, sourceUrl: video.url }; + } catch { + return null; + } +}; diff --git a/apps/api/src/agents/structureAgent.ts b/apps/api/src/agents/structureAgent.ts index 1a28ebc..18e740e 100644 --- a/apps/api/src/agents/structureAgent.ts +++ b/apps/api/src/agents/structureAgent.ts @@ -1,24 +1,97 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { + CaptionPlacement, + CaptionStyleIntent, PackagingStructure, + RATIO_TOLERANCE, RhythmStructure, ScriptStructure, + ShotScale, + VisualRole, type VideoStructureBlueprint, } from '../core/blueprint'; -import { VideoGenre } from '../core/enums'; +import { + AssetTag, + CutDensity, + SegmentRole, + SubtitleDensity, + VideoGenre, + type AssetTag as AssetTagT, +} from '../core/enums'; import { jsonSchemas } from '../core/jsonSchema'; import type { SampleAnalysis } from '../core/sample'; -import { StructureSlot } from '../core/slot'; +import { StructureSlot, type StructureSlot as StructureSlotT } from '../core/slot'; +import { MotionPreset, TransitionPreset } from '../core/timeline'; import { type ChatFn, chatJson } from '../llm/ark'; +const DraftCaptionStyleIntent = z.object({ + placement: z.string().optional(), + density: z.string().optional(), + bilingualLike: z.boolean().default(false), + notes: z.string().optional(), +}); +type DraftCaptionStyleIntent = z.infer; + +const DraftSegment = z.object({ + role: SegmentRole, + label: z.string().optional(), + durationRatio: z.number().min(0).max(1), + intent: z.string(), + copyPattern: z.string(), + visualRole: z.string().optional(), + shotScale: z.string().optional(), + motionIntent: z.string().optional(), + transitionIntent: z.string().optional(), + captionStyle: DraftCaptionStyleIntent.optional(), +}); +type DraftSegment = z.infer; + +const DraftScriptStructure = z + .object({ segments: z.array(DraftSegment).min(1) }) + .refine( + (s) => + Math.abs(s.segments.reduce((acc, seg) => acc + seg.durationRatio, 0) - 1) <= + RATIO_TOLERANCE, + { message: `段落 durationRatio 之和必须约等于 1(±${RATIO_TOLERANCE})` }, + ); +type DraftScriptStructure = z.infer; + +const DraftRhythmShot = z.object({ + startSec: z.number().min(0), + endSec: z.number().min(0), + visualRole: z.string().optional(), + shotScale: z.string().optional(), + motionIntent: z.string().optional(), + transitionIntent: z.string().optional(), +}); +type DraftRhythmShot = z.infer; + +const DraftRhythmStructure = z.object({ + avgShotSec: z.number().positive(), + cutDensity: CutDensity, + peakAt: z.number().min(0).max(1), + bgmBeatHints: z.array(z.string()).default([]), + shots: z.array(DraftRhythmShot).optional(), +}); +type DraftRhythmStructure = z.infer; + +const DraftStructureSlot = z.object({ + id: z.string(), + segmentRole: SegmentRole, + requiredAssetTypes: z.array(z.string()).default(['b_roll']), + minDurationSec: z.number().positive().optional(), + optional: z.boolean().default(false), +}); +type DraftStructureSlot = z.infer; + /** 让模型产出的部分(id / sourceSampleId / evidence 由我们填,保证 evidence 是机器证据)。 */ export const BlueprintDraft = z.object({ videoGenre: VideoGenre, - scriptStructure: ScriptStructure, - rhythmStructure: RhythmStructure, + scriptStructure: DraftScriptStructure, + rhythmStructure: DraftRhythmStructure, packagingStructure: PackagingStructure.optional(), - slots: z.array(StructureSlot).default([]), + slots: z.array(DraftStructureSlot).default([]), rationale: z.string().default(''), }); export type BlueprintDraft = z.infer; @@ -46,8 +119,12 @@ const SYSTEM_PROMPT = [ '段落 role 用通用叙事节拍:hook(开场抓人) / setup(铺垫·背景·问题) / develop(主体展开) / climax(高潮·重点·反转) / closing(收尾·表达·号召);各段 durationRatio 之和必须等于 1。', '结构服务于**观众(观看者)**的注意力与观看体验(不是面向购买用户);rationale 用观众视角解释为什么这样编排。', 'label 给该段起一个贴合本片体裁的名字(如"身份反转""步骤2""卖点拆解"),可选。', + '每个 segment 尽量补充可迁移的视觉语法:visualRole(establishing/person_in_scene/detail/action/proof/b_roll/transition_card/cta_card)、shotScale(wide/medium/close/macro)、motionIntent(static/ken_burns_in/push_in/push_out/pan_left/pan_right/pan_up/pan_down/snap_zoom)、transitionIntent(cut/crossfade/whip_cut/snap_cut),以及 captionStyle(placement/density/bilingualLike/notes)。captionStyle.placement 只能写 none/top/center/lower_third/bottom;left/right 这类横向位置请写进 notes,不要写进 placement。', + '注意:shotScale 是景别,只能写 wide / medium / close / macro;static 表示画面静止,必须写在 motionIntent,不能写在 shotScale。', 'cutDensity 依据平均镜头时长判断(越短越偏 high);peakAt 是高潮的相对位置(0–1)。', + 'rhythmStructure.shots 如能从切点或镜头描述中判断,也可以为每个逻辑镜头补充 visualRole / shotScale / motionIntent / transitionIntent。', 'slots 标注关键段落所需素材类型,取值:talking_head / product_closeup / usage_demo / comparison / b_roll / text_card;按本片真实内容选,不要硬凑带货素材。', + '注意:visualRole 的 action / cta_card / transition_card 等只能写在 segment 或 rhythmStructure.shots,绝不能写进 slots.requiredAssetTypes;slots.requiredAssetTypes 只能使用 talking_head / product_closeup / usage_demo / comparison / b_roll / text_card。', '只输出 JSON,字段:videoGenre, scriptStructure, rhythmStructure, packagingStructure, slots, rationale。不要输出 evidence,不要输出代码块标记。', ].join('\n'); @@ -88,18 +165,294 @@ export async function runStructureAgent( { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: user }, ], - { chatFn: opts.chatFn, temperature: 0.3, maxTokens: 2048 }, + { chatFn: opts.chatFn, temperature: 0.3, maxTokens: 2048, traceName: 'structure_agent' }, ); return { id: `bp_${randomUUID().slice(0, 8)}`, sourceSampleId: analysis.sampleId, videoGenre: draft.videoGenre, - scriptStructure: draft.scriptStructure, - rhythmStructure: draft.rhythmStructure, + scriptStructure: normalizeScriptStructure(draft.scriptStructure), + rhythmStructure: normalizeRhythmStructure(draft.rhythmStructure), packagingStructure: draft.packagingStructure, - slots: draft.slots, + slots: normalizeSlots(draft.slots), evidence: analysis.evidence, // 机器证据来自信号层 rationale: draft.rationale, // 模型自述 }; } + +function normalizeScriptStructure(scriptStructure: DraftScriptStructure): z.infer { + return ScriptStructure.parse({ + segments: scriptStructure.segments.map(normalizeSegment), + }); +} + +function normalizeRhythmStructure(rhythmStructure: DraftRhythmStructure): z.infer { + return RhythmStructure.parse( + omitUndefined({ + ...rhythmStructure, + shots: rhythmStructure.shots?.map(normalizeRhythmShot), + }), + ); +} + +function normalizeSegment(segment: DraftSegment): z.infer['segments'][number] { + return omitUndefined({ + ...segment, + visualRole: normalizeVisualRole(segment.visualRole), + shotScale: normalizeShotScale(segment.shotScale) ?? normalizeShotScale(segment.motionIntent), + motionIntent: normalizeMotionIntent(segment.motionIntent) ?? normalizeMotionIntent(segment.shotScale), + transitionIntent: normalizeTransitionIntent(segment.transitionIntent), + captionStyle: normalizeCaptionStyle(segment.captionStyle), + }); +} + +function normalizeRhythmShot( + shot: DraftRhythmShot, +): NonNullable['shots']>[number] { + return omitUndefined({ + ...shot, + visualRole: normalizeVisualRole(shot.visualRole), + shotScale: normalizeShotScale(shot.shotScale) ?? normalizeShotScale(shot.motionIntent), + motionIntent: normalizeMotionIntent(shot.motionIntent) ?? normalizeMotionIntent(shot.shotScale), + transitionIntent: normalizeTransitionIntent(shot.transitionIntent), + }); +} + +function normalizeSlots(slots: DraftStructureSlot[]): StructureSlotT[] { + return slots.map((slot) => { + const requiredAssetTypes = unique( + slot.requiredAssetTypes.map(normalizeAssetTag).filter((tag): tag is AssetTagT => Boolean(tag)), + ); + return StructureSlot.parse({ + ...slot, + requiredAssetTypes: requiredAssetTypes.length ? requiredAssetTypes : ['b_roll'], + }); + }); +} + +function normalizeAssetTag(raw: string): AssetTagT | undefined { + const tag = raw.trim().toLowerCase(); + if ((AssetTag.options as string[]).includes(tag)) return tag as AssetTagT; + const map: Record = { + action: 'usage_demo', + process: 'usage_demo', + demo: 'usage_demo', + proof: 'comparison', + compare: 'comparison', + detail: 'product_closeup', + close: 'product_closeup', + closeup: 'product_closeup', + establishing: 'b_roll', + person_in_scene: 'b_roll', + transition_card: 'text_card', + cta_card: 'text_card', + title_card: 'text_card', + card: 'text_card', + }; + return map[tag]; +} + +function normalizeCaptionStyle( + raw: DraftCaptionStyleIntent | undefined, +): z.infer | undefined { + if (!raw) return undefined; + return CaptionStyleIntent.parse( + omitUndefined({ + placement: normalizeCaptionPlacement(raw.placement), + density: normalizeSubtitleDensity(raw.density), + bilingualLike: raw.bilingualLike, + notes: raw.notes, + }), + ); +} + +function normalizeCaptionPlacement(raw: string | undefined): z.infer | undefined { + const value = normalizeToken(raw); + if (!value) return undefined; + if (isEnumOption(CaptionPlacement.options, value)) return value; + const map: Record> = { + upper: 'top', + header: 'top', + title: 'top', + 上方: 'top', + middle: 'center', + centered: 'center', + centre: 'center', + side: 'center', + left: 'center', + right: 'center', + sidebar: 'center', + side_note: 'center', + lower: 'lower_third', + lowerthird: 'lower_third', + lower_third_caption: 'lower_third', + lowerthird_caption: 'lower_third', + bottom_third: 'lower_third', + footer: 'bottom', + below: 'bottom', + 下方: 'bottom', + none_caption: 'none', + no_caption: 'none', + no_subtitle: 'none', + }; + return map[value]; +} + +function normalizeSubtitleDensity(raw: string | undefined): z.infer | undefined { + const value = normalizeToken(raw); + if (!value) return undefined; + if (isEnumOption(SubtitleDensity.options, value)) return value; + const map: Record> = { + low: 'sparse', + light: 'sparse', + few: 'sparse', + sparse_caption: 'sparse', + normal: 'medium', + moderate: 'medium', + mid: 'medium', + frequent: 'dense', + high: 'dense', + heavy: 'dense', + many: 'dense', + }; + return map[value]; +} + +function normalizeVisualRole(raw: string | undefined): z.infer | undefined { + const value = normalizeToken(raw); + if (!value) return undefined; + if (isEnumOption(VisualRole.options, value)) return value; + const map: Record> = { + action_scene: 'action', + activity: 'action', + broll: 'b_roll', + b_roll_scene: 'b_roll', + closeup: 'detail', + close_up: 'detail', + detail_shot: 'detail', + product_closeup: 'detail', + proof_shot: 'proof', + result: 'proof', + establishing_shot: 'establishing', + environment: 'establishing', + scene_setting: 'establishing', + talking_head: 'person_in_scene', + person: 'person_in_scene', + people: 'person_in_scene', + title_card: 'transition_card', + text_card: 'transition_card', + cta: 'cta_card', + call_to_action: 'cta_card', + }; + return map[value]; +} + +function normalizeShotScale(raw: string | undefined): z.infer | undefined { + const value = normalizeToken(raw); + if (!value) return undefined; + if (isEnumOption(ShotScale.options, value)) return value; + const map: Record> = { + long: 'wide', + wide_shot: 'wide', + full: 'wide', + full_shot: 'wide', + establishing: 'wide', + establishing_shot: 'wide', + 全景: 'wide', + 远景: 'wide', + medium_shot: 'medium', + mid: 'medium', + mid_shot: 'medium', + 中景: 'medium', + closeup: 'close', + close_up: 'close', + close_shot: 'close', + close_up_shot: 'close', + tight: 'close', + 特写: 'close', + 近景: 'close', + macro_shot: 'macro', + extreme_closeup: 'macro', + extreme_close_up: 'macro', + 微距: 'macro', + }; + return map[value]; +} + +function normalizeMotionIntent(raw: string | undefined): z.infer | undefined { + const value = normalizeToken(raw); + if (!value) return undefined; + if (isEnumOption(MotionPreset.options, value)) return value; + const map: Record> = { + still: 'static', + fixed: 'static', + locked: 'static', + locked_off: 'static', + stable: 'static', + steady: 'static', + no_motion: 'static', + static_shot: 'static', + 静止: 'static', + 固定: 'static', + zoom_in: 'push_in', + slow_push_in: 'push_in', + dolly_in: 'push_in', + zoom_out: 'push_out', + pull_out: 'push_out', + dolly_out: 'push_out', + panleft: 'pan_left', + left_pan: 'pan_left', + panright: 'pan_right', + right_pan: 'pan_right', + tilt_up: 'pan_up', + panup: 'pan_up', + tilt_down: 'pan_down', + pandown: 'pan_down', + punch_in: 'snap_zoom', + quick_zoom: 'snap_zoom', + snap_in: 'snap_zoom', + }; + return map[value]; +} + +function normalizeTransitionIntent(raw: string | undefined): z.infer | undefined { + const value = normalizeToken(raw); + if (!value) return undefined; + if (isEnumOption(TransitionPreset.options, value)) return value; + const map: Record> = { + hard_cut: 'cut', + direct_cut: 'cut', + straight_cut: 'cut', + 剪切: 'cut', + fade: 'crossfade', + dissolve: 'crossfade', + fade_in: 'crossfade', + fade_out: 'crossfade', + 叠化: 'crossfade', + whip_pan: 'whip_cut', + whip: 'whip_cut', + 甩镜: 'whip_cut', + jump_cut: 'snap_cut', + smash_cut: 'snap_cut', + quick_cut: 'snap_cut', + }; + return map[value]; +} + +function normalizeToken(raw: string | undefined): string | undefined { + const value = raw?.trim().toLowerCase().replace(/[\s-]+/g, '_'); + return value || undefined; +} + +function isEnumOption(options: readonly T[], value: string): value is T { + return (options as readonly string[]).includes(value); +} + +function omitUndefined>(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as T; +} + +function unique(items: T[]): T[] { + return [...new Set(items)]; +} diff --git a/apps/api/src/agents/tagAsset.ts b/apps/api/src/agents/tagAsset.ts index 2a04094..c507cd3 100644 --- a/apps/api/src/agents/tagAsset.ts +++ b/apps/api/src/agents/tagAsset.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { z } from 'zod'; -import { AssetTag } from '../core/enums'; +import { AssetTag, StoryFunction, VisualFunction } from '../core/enums'; import type { MediaType } from '../core/enums'; import type { TaggedAsset } from '../core/slot'; import { type ChatFn, chatJson } from '../llm/ark'; @@ -11,6 +11,12 @@ import { runFfmpeg } from '../render/ffmpeg'; const TagOut = z.object({ assetTags: z.array(AssetTag).min(1), + storyRoles: z.array(StoryFunction).default([]), + narrativeUse: StoryFunction.optional(), + visualFunctions: z.array(VisualFunction).default([]), + shotScale: z.enum(['wide', 'medium', 'close', 'macro']).optional(), + visualMood: z.array(z.string()).default([]), + visualClusterId: z.string().optional(), summary: z.string(), confidence: z.number().min(0).max(1), }); @@ -18,7 +24,12 @@ const TagOut = z.object({ const SYSTEM = [ '你给视频/图片素材打类型标签,供「结构槽位」匹配使用。', '可选类型(可多选):talking_head(人物出镜口播/采访) / product_closeup(产品或主体特写) / usage_demo(使用或操作过程) / comparison(对比) / b_roll(空镜/场景/赛事/氛围画面) / text_card(纯文字画面)。', - '基于给的代表帧判断。只输出 JSON:{"assetTags": string[](从上面枚举里选,至少 1 个), "summary": 一句话中文描述, "confidence": 0-1}。不要代码块标记。', + '同时判断素材在故事里适合承担的叙事职责 storyRoles(可多选):opening_hook / context / character / action / detail / contrast / proof / turn / payoff / cta / mood / transition。', + '同时判断通用 visualFunctions(可多选):establish_context / introduce_subject / show_action / show_detail / show_progression / show_result / show_emotion / show_scale / bridge_transition / call_to_action。', + 'shotScale 选择 wide / medium / close / macro,描述素材主要景别。', + 'narrativeUse 选择最适合的位置;visualMood 用 1-3 个中文短词描述画面情绪,如安静、冲突、轻松、高级、孤独、热闹。', + 'visualClusterId 用稳定短标签描述视觉场景簇,如 scene:blue_water / scene:person / scene:waterfall / scene:sky_branch / scene:product,用来避免相似素材反复出现。', + '基于给的代表帧判断。只输出 JSON:{"assetTags": string[], "storyRoles": string[], "narrativeUse": string, "visualFunctions": string[], "shotScale": string, "visualMood": string[], "visualClusterId": string, "summary": 一句话中文描述, "confidence": 0-1}。不要代码块标记。', ].join('\n'); export interface TagAssetOptions { @@ -54,12 +65,18 @@ export async function tagAsset(opts: TagAssetOptions): Promise { ], }, ], - { chatFn: opts.chatFn, temperature: 0.2, maxTokens: 400 }, + { chatFn: opts.chatFn, temperature: 0.2, maxTokens: 400, traceName: 'asset_tag_agent' }, ); return { id: opts.id ?? `asset_${randomUUID().slice(0, 8)}`, mediaType: opts.mediaType, assetTags: out.assetTags, + storyRoles: out.storyRoles, + narrativeUse: out.narrativeUse, + visualFunctions: out.visualFunctions, + shotScale: out.shotScale, + visualMood: out.visualMood, + visualClusterId: out.visualClusterId, durationSec: opts.durationSec, confidence: out.confidence, summary: out.summary, diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index e118a14..53c206c 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -18,6 +18,16 @@ export interface ArkConfig { baseUrl: string; } +export interface AsrConfig { + apiKey?: string; + /** OpenAI-compatible audio transcription base URL, e.g. https://api.openai.com/v1 */ + baseUrl: string; + /** Model name for transcription, e.g. whisper-1 or a provider-specific ASR model. */ + model: string; + /** Request path under baseUrl. Defaults to /audio/transcriptions. */ + path: string; +} + /** 读取火山方舟配置;缺关键项时返回 null(便于在无 key 环境优雅降级)。 */ export function getArkConfig(): ArkConfig | null { const apiKey = process.env.ARK_API_KEY?.trim(); @@ -34,3 +44,23 @@ export function requireArkConfig(): ArkConfig { } return cfg; } + +/** Pexels 免费视频检索 API key(可选);缺则 stock 补全自动回退为 text_card。 */ +export function getPexelsKey(): string | null { + return process.env.PEXELS_API_KEY?.trim() || null; +} + +/** Optional OpenAI-compatible ASR endpoint. Missing config means ASR is skipped. */ +export function getAsrConfig(): AsrConfig | null { + const baseUrl = process.env.ASR_BASE_URL?.trim(); + const model = process.env.ASR_MODEL?.trim(); + if (!baseUrl || !model) return null; + const apiKey = process.env.ASR_API_KEY?.trim() || process.env.ARK_API_KEY?.trim(); + const path = process.env.ASR_TRANSCRIBE_PATH?.trim() || '/audio/transcriptions'; + return { + apiKey, + baseUrl: baseUrl.replace(/\/+$/, ''), + model, + path: path.startsWith('/') ? path : `/${path}`, + }; +} diff --git a/apps/api/src/core/__tests__/migration.test.ts b/apps/api/src/core/__tests__/migration.test.ts index 6c234ac..a72d349 100644 --- a/apps/api/src/core/__tests__/migration.test.ts +++ b/apps/api/src/core/__tests__/migration.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { sampleAnalysis } from '../mocks/sample-analysis'; import { sampleBlueprint } from '../mocks/sample-blueprint'; -import { runRuleBasedMigration } from '../migration'; +import { rankLearnedPatterns, runRuleBasedMigration } from '../migration'; import { validateMigrationPlan, validateTimelineTraceability } from '../validate'; import type { TaggedAsset } from '../slot'; +import { LearnedSamplePattern, type LearnedSamplePattern as LearnedSamplePatternT } from '../sampleLearning'; +import type { TemplateProfile } from '../template'; const assets: TaggedAsset[] = [ { @@ -15,6 +18,80 @@ const assets: TaggedAsset[] = [ }, ]; +const cameraCarouselTemplateProfile: TemplateProfile = { + id: 'tpl_camera_carousel', + source: 'user_sample', + durationSec: 5.8, + sourceAspect: '720:456', + targetCanvasAspect: '9:16', + layoutPreset: 'camera_carousel', + frameStyle: { + backgroundColor: '#050505', + matte: true, + roundedMask: true, + labelStyle: 'camera_ui', + viewport: { aspectRatio: '592:288', x: 0.089, y: 0.246, width: 0.822, height: 0.632 }, + }, + motionLanguage: { + internalMotionIntensity: 'high', + hasMaskReveals: true, + hasViewportSlides: true, + preferredMotionPreset: 'reveal_pan', + preferredTransitionPreset: 'whip_cut', + notes: ['固定黑色相机外壳,内部素材从右向左轮播。'], + }, + audioOnsets: [{ timeSec: 0.42, relativeTime: 0.072, strength: 'strong', energyDb: -8 }], + events: [ + { + kind: 'carousel_slide', + timeSec: 0.42, + relativeTime: 0.072, + strength: 'strong', + direction: 'left', + nearestOnsetSec: 0.42, + description: '内部轮播素材滑入。', + }, + ], + strategySummary: '保留相机 UI 外壳,把目标素材作为内部横向轮播条渲染。', + renderHints: ['camera_carousel', 'carousel_strip', 'sample_audio_preferred'], +}; + +const landscapeCinematicTemplateProfile: TemplateProfile = { + id: 'tpl_landscape_cinematic', + source: 'user_sample', + durationSec: 5.8, + sourceAspect: '1920:1080', + targetCanvasAspect: '9:16', + layoutPreset: 'cinematic_matte', + frameStyle: { + backgroundColor: '#050505', + matte: true, + roundedMask: true, + labelStyle: 'film_code', + viewport: { aspectRatio: '16:9', x: 0.04, y: 0.2, width: 0.92, height: 0.58 }, + }, + motionLanguage: { + internalMotionIntensity: 'high', + hasMaskReveals: true, + hasViewportSlides: true, + preferredMotionPreset: 'reveal_pan', + preferredTransitionPreset: 'whip_cut', + notes: ['横版 cinematic viewport。'], + }, + audioOnsets: [{ timeSec: 0.8, relativeTime: 0.14, strength: 'strong', energyDb: -9 }], + events: [{ + kind: 'mask_reveal', + timeSec: 0.8, + relativeTime: 0.14, + strength: 'strong', + direction: 'left', + nearestOnsetSec: 0.8, + description: '横版遮罩移动。', + }], + strategySummary: '保留横版黑底画幅框架。', + renderHints: ['cinematic_matte', 'reveal_pan'], +}; + describe('runRuleBasedMigration', () => { it('匹配可用素材,缺口产出 FillArtifact,并生成可追溯时间线', () => { const migration = runRuleBasedMigration({ @@ -33,7 +110,29 @@ describe('runRuleBasedMigration', () => { ); expect(migration.gaps.map((g) => g.slotId)).toEqual(['slot_hook', 'slot_proof']); expect(migration.fills.length).toBeGreaterThanOrEqual(migration.gaps.length); - expect(migration.timeline.items).toHaveLength(sampleBlueprint.scriptStructure.segments.length); + const visualItems = migration.timeline.items.filter((item) => item.track !== 'audio'); + expect(visualItems.length).toBeGreaterThanOrEqual(sampleBlueprint.scriptStructure.segments.length); + expect(visualItems.every((item) => item.motionPreset)).toBe(true); + expect( + visualItems + .filter((item) => item.source.kind === 'user_asset') + .some((item) => item.motionPreset && item.motionPreset !== 'static'), + ).toBe(true); + expect( + migration.evidence.some((e) => e.type === 'qc_auto_revision' && e.detail.includes('可执行运镜不足')), + ).toBe(true); + expect(visualItems.some((item) => item.sourceInSec != null)).toBe(true); + expect(migration.timeline.items.some((item) => item.track === 'audio')).toBe(true); + expect(migration.fills.some((fill) => fill.kind === 'copy_completion')).toBe(true); + expect(migration.directorPlan.shots.length).toBe(visualItems.length); + expect(visualItems.every((item) => item.shotRef)).toBe(true); + expect(migration.evidence.some((e) => e.type === 'director_plan')).toBe(true); + expect(migration.creativeBrief.selectedHookId).toBeTruthy(); + expect(migration.beatMap.beats.length).toBeGreaterThan(migration.script.length); + expect(migration.shotList.shots).toHaveLength(migration.beatMap.beats.length); + expect(migration.assetPlan.items).toHaveLength(migration.shotList.shots.length); + expect(migration.editDecisionList.decisions).toHaveLength(migration.timeline.items.length); + expect(migration.qcReport.totalScore).toBeGreaterThan(0); const trace = validateTimelineTraceability(migration.timeline, { blueprint: sampleBlueprint, @@ -43,4 +142,3493 @@ describe('runRuleBasedMigration', () => { expect(trace.errors).toEqual([]); expect(trace.ok).toBe(true); }); -}); + + it('商业迁移排序会降权弱商业氛围 pattern,优先可转化 pattern', () => { + const basePattern: LearnedSamplePatternT = LearnedSamplePattern.parse({ + id: 'pattern_base', + scope: 'global', + sourceSampleId: 'sample_base', + name: 'base', + summary: 'base', + videoGenre: 'product', + tags: ['product', 'hook', 'develop', 'closing'], + reusablePatternName: 'base', + formula: 'hook -> develop -> closing', + source: { filename: 'base.mp4', durationSec: 24, aspectRatio: '1080:1920', shotCount: 8 }, + segments: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + ...segment, + watchingPurpose: segment.intent, + })), + pacing: { durationSec: 24, shotCount: 8, avgShotSec: 2.4, cutDensity: 'high', peakAt: 0.7, beatHints: [] }, + learnedDimensions: { + scriptStructure: { + formula: 'hook -> proof -> purchase', + segmentCount: sampleBlueprint.scriptStructure.segments.length, + segments: [], + notes: [], + }, + shotRhythm: { + durationSec: 24, + shotCount: 8, + avgShotSec: 2.4, + cutDensity: 'high', + peakAt: 0.7, + beatHints: [], + rhythmNotes: [], + }, + subtitleStyle: { density: 'dense', placement: 'lower_third', typography: 'clean', animation: 'cut', notes: [] }, + visualPackaging: { overlayStyle: 'clean', notes: [] }, + transitions: { style: 'cut', frequency: '高频切换', notableTransitions: [], executableTechniques: [] }, + bgmSync: { hasAudio: true, beatHints: [], syncStrategy: 'coarse', confidence: 'medium', limitations: [] }, + }, + slotNeeds: sampleBlueprint.slots.map((slot) => ({ + slotId: slot.id, + segmentRole: slot.segmentRole, + requiredAssetTypes: slot.requiredAssetTypes, + minDurationSec: slot.minDurationSec, + optional: slot.optional, + })), + evidence: [], + rationale: '', + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + const vibePattern: LearnedSamplePatternT = LearnedSamplePattern.parse({ + ...basePattern, + id: 'pattern_vibe', + name: '产品氛围号引流', + videoGenre: 'product', + tags: ['product', 'hook', 'develop', 'closing'], + formula: '氛围感开场 -> 多场景铺陈 -> 平台搜索行动引导', + qualityTags: { + patternDepth: 'full_story', + ctaType: 'platform_follow', + visualBridgePolicy: { use: 'blocked', reasons: ['平台水印'] }, + commercialUsefulness: 'weak', + recommendedUse: 'secondary_story', + warnings: [], + }, + updatedAt: '2026-06-08T00:00:00.000Z', + }); + const proofPattern: LearnedSamplePatternT = LearnedSamplePattern.parse({ + ...basePattern, + id: 'pattern_proof', + name: '产品证明', + qualityTags: { + patternDepth: 'full_story', + ctaType: 'purchase', + visualBridgePolicy: { use: 'learn_only', reasons: ['产品证明画面不桥接'] }, + commercialUsefulness: 'strong', + recommendedUse: 'primary_story', + warnings: [], + }, + updatedAt: '2026-06-06T00:00:00.000Z', + }); + + const ranked = rankLearnedPatterns([vibePattern, proofPattern], sampleBlueprint, { + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时', '立即购买'], + purpose: 'story', + }); + + expect(ranked[0].id).toBe('pattern_proof'); + }); + + it('优先使用音频素材作为 BGM 轨道', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_audio_asset', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + ...assets, + { + id: 'asset_bgm', + mediaType: 'audio', + assetTags: [], + durationSec: 18, + confidence: 1, + summary: '轻快咖啡广告 BGM', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + + const audioItem = migration.timeline.items.find((item) => item.track === 'audio'); + expect(audioItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_bgm' }); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.ref === 'asset_bgm')).toBe(true); + }); + + it('用户指定 BGM 时优先使用指定音频素材', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_explicit_bgm', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + ...assets, + { + id: 'asset_auto_audio', + mediaType: 'audio', + assetTags: [], + durationSec: 18, + confidence: 1, + summary: '普通上传音频', + }, + { + id: 'asset_selected_bgm', + mediaType: 'audio', + assetTags: [], + durationSec: 18, + isBgm: true, + confidence: 1, + summary: '用户指定 BGM', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + + const audioItem = migration.timeline.items.find((item) => item.track === 'audio'); + expect(audioItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_selected_bgm' }); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.ref === 'asset_selected_bgm')).toBe(true); + }); + + it('自动复用视频原声时跳过近似静音的音轨', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_skip_silent_video_audio', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + ...assets.map((asset) => ({ ...asset, hasAudio: false })), + { + id: 'asset_silent_video', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 12, + hasAudio: true, + audioMeanVolumeDb: -91, + audioMaxVolumeDb: -91, + silentAudioRisk: true, + confidence: 1, + summary: '带静音 AAC 外壳的视频', + }, + { + id: 'asset_audible_video', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 12, + hasAudio: true, + audioMeanVolumeDb: -29.3, + audioMaxVolumeDb: -14, + silentAudioRisk: false, + confidence: 1, + summary: '有真实环境声的视频', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + + const audioItem = migration.timeline.items.find((item) => item.track === 'audio'); + expect(audioItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_audible_video' }); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.ref === 'asset_audible_video')).toBe(true); + }); + + it('把样例 templateProfile 写入 timeline 供 Remotion 复刻画幅模板', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_template_profile', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + sampleAnalysis: { + ...sampleAnalysis, + templateProfile: { + id: 'tpl_sample_001', + source: 'user_sample', + durationSec: 5.8, + sourceAspect: '720:456', + targetCanvasAspect: '9:16', + layoutPreset: 'cinematic_matte', + frameStyle: { + backgroundColor: '#050505', + matte: true, + roundedMask: true, + labelStyle: 'film_code', + viewport: { aspectRatio: '16:9', x: 0.04, y: 0.2, width: 0.92, height: 0.58 }, + }, + motionLanguage: { + internalMotionIntensity: 'high', + hasMaskReveals: true, + hasViewportSlides: true, + preferredMotionPreset: 'reveal_pan', + preferredTransitionPreset: 'whip_cut', + notes: ['模板内运动'], + }, + audioOnsets: [{ timeSec: 0.8, relativeTime: 0.14, strength: 'strong', energyDb: -9 }], + events: [{ + kind: 'mask_reveal', + timeSec: 0.8, + relativeTime: 0.14, + strength: 'strong', + direction: 'left', + nearestOnsetSec: 0.8, + description: '模板遮罩', + }], + strategySummary: '保留黑底横版画幅框架。', + renderHints: ['cinematic_matte', 'reveal_pan'], + }, + }, + assets, + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + + expect(migration.timeline.templateProfile?.layoutPreset).toBe('cinematic_matte'); + expect(migration.evidence.some((e) => e.type === 'template_profile')).toBe(true); + expect(validateMigrationPlan(migration).ok).toBe(true); + }); + + it('相机轮播短模板默认继承当前样例音频和样例时长', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_camera_carousel', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + sampleAnalysis: { + ...sampleAnalysis, + templateProfile: cameraCarouselTemplateProfile, + }, + assets: [ + ...assets, + { + id: 'asset_generic_audio', + mediaType: 'audio', + assetTags: [], + durationSec: 18, + confidence: 1, + summary: '普通上传音频', + }, + ], + referenceAssets: [ + { + id: 'ref_current_sample', + sourceRole: 'current_sample', + sourceSampleId: sampleBlueprint.sourceSampleId, + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 5.8, + hasAudio: true, + confidence: 0.45, + summary: '当前相机轮播样例', + sourcePath: '/tmp/current-camera-carousel.mov', + }, + ], + topic: '旅行随拍', + sellingPoints: ['轻快轮播', '相机感'], + }); + + const audioItem = migration.timeline.items.find((item) => item.track === 'audio'); + expect(migration.timeline.durationSec).toBe(5.8); + expect(migration.timeline.templateProfile?.layoutPreset).toBe('camera_carousel'); + expect(audioItem?.source).toEqual({ kind: 'raw', path: '/tmp/current-camera-carousel.mov' }); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.ref === 'ref_current_sample')).toBe(true); + expect(validateMigrationPlan(migration).ok).toBe(true); + }); + + it('相机轮播模板会把多张上传图片展开为多个 slide,而不是只复用第一张素材', () => { + const carouselBlueprint = { + ...sampleBlueprint, + videoGenre: 'showcase' as const, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '相机轮播', + durationRatio: 1, + intent: '用多张旅行照片快速建立氛围', + copyPattern: '短标题 + 图片轮播', + }, + ], + }, + rhythmStructure: { + ...sampleBlueprint.rhythmStructure, + avgShotSec: 0.9, + cutDensity: 'high' as const, + }, + slots: [ + { + id: 'slot_carousel', + segmentRole: 'hook' as const, + requiredAssetTypes: ['text_card', 'b_roll'] as TaggedAsset['assetTags'], + optional: false, + }, + ], + }; + const carouselAssets: TaggedAsset[] = Array.from({ length: 12 }, (_, index) => ({ + id: `asset_trip_${index + 1}`, + mediaType: 'image', + assetTags: ['b_roll'], + durationSec: 5, + confidence: 0.9, + summary: `旅行照片 ${index + 1}`, + })); + const denseCarouselTemplateProfile: TemplateProfile = { + ...cameraCarouselTemplateProfile, + durationSec: 5.785, + events: Array.from({ length: 15 }, (_, index) => ({ + kind: 'carousel_slide' as const, + timeSec: Number((0.78 + index * 0.333).toFixed(3)), + relativeTime: Number(((0.78 + index * 0.333) / 5.785).toFixed(3)), + strength: index > 6 ? ('strong' as const) : ('medium' as const), + direction: 'left' as const, + description: '内部轮播素材滑入。', + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_camera_carousel_multi_asset', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: carouselBlueprint, + sampleAnalysis: { + ...sampleAnalysis, + templateProfile: denseCarouselTemplateProfile, + }, + assets: carouselAssets, + topic: '旅行vlog', + sellingPoints: ['轻快轮播'], + }); + + const visualItems = migration.timeline.items.filter((item) => item.track !== 'audio'); + const usedAssetIds = visualItems.flatMap((item) => + item.source.kind === 'user_asset' ? [item.source.assetId] : [], + ); + + expect(migration.matches[0]).toMatchObject({ slotId: 'slot_carousel', status: 'matched' }); + expect(migration.gaps).toHaveLength(0); + expect(visualItems).toHaveLength(8); + expect(visualItems.every((item) => item.endSec > item.startSec)).toBe(true); + expect(new Set(usedAssetIds).size).toBeGreaterThan(1); + expect(migration.directorPlan.shots).toHaveLength(visualItems.length); + expect(validateMigrationPlan(migration).ok).toBe(true); + }); + + it('相机轮播短模板中用户显式 BGM 仍优先于样例音频', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_camera_carousel_explicit_bgm', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + sampleAnalysis: { + ...sampleAnalysis, + templateProfile: cameraCarouselTemplateProfile, + }, + assets: [ + ...assets, + { + id: 'asset_selected_bgm', + mediaType: 'audio', + assetTags: [], + durationSec: 18, + isBgm: true, + confidence: 1, + summary: '用户指定 BGM', + }, + ], + referenceAssets: [ + { + id: 'ref_current_sample', + sourceRole: 'current_sample', + sourceSampleId: sampleBlueprint.sourceSampleId, + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 5.8, + hasAudio: true, + confidence: 0.45, + summary: '当前相机轮播样例', + sourcePath: '/tmp/current-camera-carousel.mov', + }, + ], + topic: '旅行随拍', + sellingPoints: ['轻快轮播', '相机感'], + }); + + const audioItem = migration.timeline.items.find((item) => item.track === 'audio'); + expect(migration.timeline.durationSec).toBe(8); + expect(audioItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_selected_bgm' }); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.ref === 'asset_selected_bgm')).toBe(true); + }); + + it('上传视频明确无音轨时,BGM fallback 到当前样例音频', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_sample_audio_fallback', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + ...assets[0], + hasAudio: false, + }, + ], + referenceAssets: [ + { + id: 'ref_current_sample_audio', + sourceRole: 'current_sample', + sourceSampleId: sampleBlueprint.sourceSampleId, + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 20, + hasAudio: true, + confidence: 0.48, + summary: '当前样例视频参考素材', + sourcePath: '/tmp/current-sample-with-audio.mp4', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + + const audioItem = migration.timeline.items.find((item) => item.track === 'audio'); + expect(audioItem?.source).toEqual({ kind: 'raw', path: '/tmp/current-sample-with-audio.mp4' }); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.ref === 'ref_current_sample_audio')).toBe(true); + }); + + it('reference 音频元数据错误时,仍从 sampleAnalysis 回退当前样例音频', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_sample_analysis_audio_fallback', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + sampleAnalysis: { + ...sampleAnalysis, + sourcePath: '/tmp/current-sample-analysis-audio.mp4', + metadata: { + ...sampleAnalysis.metadata, + hasAudio: true, + audioCodec: 'aac', + }, + }, + assets: [ + { + ...assets[0], + hasAudio: false, + }, + ], + referenceAssets: [ + { + id: 'ref_current_sample_audio_stale', + sourceRole: 'current_sample', + sourceSampleId: sampleBlueprint.sourceSampleId, + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 20, + hasAudio: false, + confidence: 0.48, + summary: '旧上下文误判为无音频的当前样例', + sourcePath: '/tmp/current-sample-analysis-audio.mp4', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + + const audioItem = migration.timeline.items.find((item) => item.track === 'audio'); + expect(audioItem?.source).toEqual({ kind: 'raw', path: '/tmp/current-sample-analysis-audio.mp4' }); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.ref === sampleBlueprint.sourceSampleId)).toBe(true); + }); + + it('关闭上传素材 BGM 复用时不生成 audio 轨道', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_no_bgm', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + ...assets, + { + id: 'asset_bgm', + mediaType: 'audio', + assetTags: [], + durationSec: 18, + confidence: 1, + summary: '轻快咖啡广告 BGM', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + reuseUploadedBgm: false, + }); + + expect(migration.timeline.items.some((item) => item.track === 'audio')).toBe(false); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.detail.includes('关闭'))).toBe(true); + }); + + it('用户显式上传的 BGM 不受自动复用开关影响', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_explicit_bgm_even_when_auto_reuse_off', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + ...assets, + { + id: 'asset_selected_bgm', + mediaType: 'audio', + assetTags: [], + durationSec: 18, + isBgm: true, + confidence: 1, + summary: '用户指定 BGM', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + reuseUploadedBgm: false, + }); + + const audioItem = migration.timeline.items.find((item) => item.track === 'audio'); + expect(audioItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_selected_bgm' }); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.ref === 'asset_selected_bgm')).toBe(true); + expect(migration.evidence.some((e) => e.type === 'bgm' && e.detail.includes('未指定 BGM'))).toBe(false); + }); + + it('图片素材会继承 DirectorPlan 的可执行运镜,视频素材默认保持稳定', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_image_motion', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + id: 'asset_product_image', + mediaType: 'image', + assetTags: ['product_closeup'], + confidence: 0.9, + summary: '产品静物图', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时', '单手开盖'], + durationSec: 12, + }); + + const imageItem = migration.timeline.items.find( + (item) => item.source.kind === 'user_asset' && item.source.assetId === 'asset_product_image', + ); + const directorShot = migration.directorPlan.shots.find((shot) => shot.shotId === imageItem?.shotRef); + expect(imageItem?.motionPreset).toBe(directorShot?.motionPreset); + expect(imageItem?.motionPreset).not.toBe('static'); + }); + + it('文字卡混合槽位可拆成真实画面 + 字幕,口播槽位仍不能被普通 b-roll 误匹配', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_text_gap', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: [ + { + id: 'slot_text', + segmentRole: 'setup', + requiredAssetTypes: ['b_roll', 'text_card'], + optional: false, + }, + { + id: 'slot_talking', + segmentRole: 'hook', + requiredAssetTypes: ['b_roll', 'talking_head'], + optional: false, + }, + ], + }, + assets, + topic: '便携咖啡杯', + durationSec: 12, + }); + + expect(migration.matches.find((m) => m.slotId === 'slot_text')?.status).toBe('matched'); + expect(migration.matches.find((m) => m.slotId === 'slot_talking')?.status).toBe('gap'); + expect(migration.gaps.map((g) => g.slotId)).toEqual(['slot_talking']); + expect(migration.gaps.map((g) => g.recommendedStrategies[0])).toEqual(['reused_clip']); + const gapFills = migration.fills.filter((fill) => ['slot_text', 'slot_talking'].includes(fill.slotId)); + expect(gapFills.length).toBeGreaterThanOrEqual(1); + expect(gapFills.every((fill) => fill.kind === 'reused_clip')).toBe(true); + expect(gapFills.every((fill) => fill.source.includes('asset_product'))).toBe(true); + }); + + it('长证明类视觉缺口不再用样例参考画面伪造核心事实', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_fill', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + referenceAssets: [ + { + id: 'ref_sample_visual', + sourceRole: 'current_sample', + sourceSampleId: sampleBlueprint.sourceSampleId, + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 30, + confidence: 0.5, + summary: '当前样例视频参考素材', + sourcePath: '/tmp/current-sample.mp4', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时', '单手开盖'], + durationSec: 30, + referenceClipMode: 'allow_reference_clip', + }); + + const proofFill = migration.fills.find((fill) => fill.slotId === 'slot_proof'); + expect(proofFill?.kind).not.toBe('reference_clip'); + expect(migration.gaps.find((gap) => gap.slotId === 'slot_proof')?.recommendedStrategies).not.toContain( + 'reference_clip', + ); + }); + + it('默认只学习参考样例,不把 reference_clip 画面放进 timeline', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_learn_only', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + referenceAssets: [ + { + id: 'ref_sample_visual', + sourceRole: 'current_sample', + sourceSampleId: sampleBlueprint.sourceSampleId, + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 30, + confidence: 0.5, + summary: '当前样例视频参考素材', + sourcePath: '/tmp/current-sample.mp4', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时', '单手开盖'], + durationSec: 30, + }); + + expect(migration.fills.every((fill) => fill.kind !== 'reference_clip')).toBe(true); + expect( + migration.gaps.flatMap((gap) => gap.recommendedStrategies).includes('reference_clip'), + ).toBe(false); + expect( + migration.evidence.some((e) => e.type === 'migration_controls' && e.detail.includes('不会作为画面进入 timeline')), + ).toBe(true); + expect(migration.visualCoverage?.referenceClipAllowed).toBe(false); + }); + + it('横屏模板遇到竖屏素材时默认触发 aspect mismatch guard', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_aspect_guard', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + sampleAnalysis: { + ...sampleAnalysis, + templateProfile: landscapeCinematicTemplateProfile, + }, + assets: [ + { + id: 'asset_portrait_close', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_action', 'show_detail'], + shotScale: 'close', + aspectRatio: '1080:1920', + durationSec: 8, + confidence: 0.9, + summary: '竖屏手部操作近景', + }, + ], + topic: '手作咖啡', + sellingPoints: ['新鲜冲煮'], + durationSec: 12, + }); + + expect(migration.timeline.templateProfile).toBeUndefined(); + expect(migration.visualCoverage?.aspectMismatch).toBe(true); + expect(migration.evidence.some((e) => e.type === 'aspect_mismatch_guard')).toBe(true); + expect( + migration.decisions.some((decision) => decision.chosen.includes('portrait_safe_template_guard')), + ).toBe(true); + }); + + it('视觉覆盖度会识别近景素材不足以支撑完整视觉功能', () => { + const baseline = runRuleBasedMigration({ + projectId: 'proj_visual_coverage_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '手作咖啡', + sellingPoints: ['新鲜冲煮'], + durationSec: 12, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: baseline.directorPlan.shots.map((shot, index) => ({ + ...shot, + visualFunctions: index === 0 + ? (['establish_context', 'show_scale'] as typeof shot.visualFunctions) + : (['show_action', 'show_detail'] as typeof shot.visualFunctions), + shotScale: index === 0 ? ('wide' as const) : ('close' as const), + })), + }; + const migration = runRuleBasedMigration({ + projectId: 'proj_visual_coverage_closeups', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + id: 'asset_hand_1', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_action', 'show_detail'], + shotScale: 'close', + durationSec: 6, + confidence: 0.92, + summary: '手部倒咖啡豆近景', + }, + { + id: 'asset_hand_2', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_action', 'show_detail'], + shotScale: 'macro', + durationSec: 6, + confidence: 0.9, + summary: '咖啡出液和杯口微距', + }, + ], + directorPlan, + topic: '手作咖啡', + sellingPoints: ['新鲜冲煮'], + durationSec: 12, + }); + + expect(migration.visualCoverage?.missingFunctions).toEqual( + expect.arrayContaining(['establish_context', 'show_scale']), + ); + expect(migration.visualCoverage?.closeUpLikeShare).toBeGreaterThan(0.7); + expect(migration.evidence.some((e) => e.type === 'visual_coverage')).toBe(true); + }); + + it('关键视觉功能缺失时会触发 shot fallback,而不是被泛化 b_roll 吞掉', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '建立场景', + durationRatio: 1, + intent: '先让观众看清环境', + copyPattern: '纯视觉开场', + }, + ], + }, + slots: [ + { + id: 'slot_scene', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + optional: false, + }, + ], + }; + const genericAssets: TaggedAsset[] = [ + { + id: 'asset_generic_broll', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 8, + confidence: 0.92, + summary: '普通素材', + }, + ]; + const referenceAssets = [ + { + id: 'ref_establishing', + sourceRole: 'current_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['establish_context', 'show_scale'] as TaggedAsset['visualFunctions'], + shotScale: 'wide' as const, + durationSec: 8, + confidence: 0.5, + summary: '参考样例里的建立场景镜头', + sourcePath: '/tmp/reference-establishing.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_visual_fallback_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: genericAssets, + referenceAssets, + referenceClipMode: 'allow_reference_clip', + topic: '旅行记录', + durationSec: 8, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: baseline.directorPlan.shots.map((shot, index) => ({ + ...shot, + visualFunctions: index === 0 + ? (['establish_context', 'show_scale'] as typeof shot.visualFunctions) + : shot.visualFunctions, + shotScale: index === 0 ? ('wide' as const) : shot.shotScale, + fallbackStrategies: index === 0 + ? (['user_asset', 'copy_completion', 'packaging_overlay', 'aigc', 'reference_clip', 'reused_clip'] as typeof shot.fallbackStrategies) + : shot.fallbackStrategies, + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_visual_fallback', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: genericAssets, + referenceAssets, + referenceClipMode: 'allow_reference_clip', + directorPlan, + topic: '旅行记录', + durationSec: 8, + }); + + expect(migration.visualCoverage?.missingFunctions).toEqual( + expect.arrayContaining(['establish_context', 'show_scale']), + ); + const firstVisualItem = migration.timeline.items.find((item) => item.track === 'video'); + expect(firstVisualItem?.source.kind).toBe('fill_artifact'); + const fillId = firstVisualItem?.source.kind === 'fill_artifact' ? firstVisualItem.source.fillArtifactId : ''; + expect(migration.fills.find((fill) => fill.id === fillId)).toMatchObject({ + kind: 'reference_clip', + source: '/tmp/reference-establishing.mp4', + }); + }); + + it('旅行图片语义缺失时用用户实景弱承接桥接,不拉无关全局教程样例', () => { + const coverageBlueprint = { + ...sampleBlueprint, + videoGenre: 'vlog' as const, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '旅行开场', + durationRatio: 1, + intent: '先建立九寨沟旅行的环境尺度', + copyPattern: '风景实景开场', + }, + ], + }, + slots: [ + { + id: 'slot_scene', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + requiredVisualFunctions: ['establish_context', 'show_scale'] as TaggedAsset['visualFunctions'], + optional: false, + }, + ], + }; + const unlabeledTravelImages: TaggedAsset[] = [ + { + id: 'asset_heic_8064', + mediaType: 'image', + assetTags: ['b_roll'], + durationSec: 5, + confidence: 1, + summary: 'IMG_8064.HEIC', + }, + { + id: 'asset_heic_8070', + mediaType: 'image', + assetTags: ['b_roll'], + durationSec: 5, + confidence: 1, + summary: 'IMG_8070.HEIC', + }, + ]; + const unrelatedLearnedReference = [ + { + id: 'ref_global_transition_tutorial', + sourceRole: 'learned_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['establish_context', 'show_scale'] as TaggedAsset['visualFunctions'], + durationSec: 12, + confidence: 0.7, + summary: '学习样例「教程模式」参考素材:碎裂转场教程,搜索获取完整教程', + sourcePath: '/tmp/unrelated-transition-tutorial.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_travel_unlabeled_reference_base', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: unlabeledTravelImages, + referenceAssets: unrelatedLearnedReference, + visualGapMode: 'reference_bridge', + topic: '九寨沟旅游 vlog', + durationSec: 8, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: baseline.directorPlan.shots.map((shot, index) => ({ + ...shot, + slotId: 'slot_scene', + shotId: `shot_travel_scene_${index + 1}`, + segmentRole: 'hook' as const, + storyFunction: 'opening_hook' as const, + visualRole: 'establishing' as const, + shotScale: 'wide' as const, + visualFunctions: ['establish_context', 'show_scale'] as typeof shot.visualFunctions, + assetNeed: ['b_roll'] as typeof shot.assetNeed, + fallbackStrategies: ['user_asset', 'reference_clip', 'copy_completion', 'packaging_overlay', 'reused_clip'] as typeof shot.fallbackStrategies, + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_travel_unlabeled_reference_guard', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: unlabeledTravelImages, + referenceAssets: unrelatedLearnedReference, + visualGapMode: 'reference_bridge', + directorPlan, + topic: '九寨沟旅游 vlog', + durationSec: 8, + }); + + expect(migration.visualCoverage?.missingFunctions).not.toEqual( + expect.arrayContaining(['establish_context', 'show_scale']), + ); + expect(migration.visualCoverage?.weakFunctions).toEqual( + expect.arrayContaining(['establish_context', 'show_scale']), + ); + expect(migration.fills.every((fill) => fill.kind !== 'reference_clip')).toBe(true); + const fillById = new Map(migration.fills.map((fill) => [fill.id, fill])); + expect( + migration.timeline.items.filter((item) => item.track !== 'audio').every((item) => { + if (item.source.kind !== 'fill_artifact') return true; + return fillById.get(item.source.fillArtifactId)?.kind !== 'reference_clip'; + }), + ).toBe(true); + expect(migration.timeline.items.some((item) => item.source.kind === 'user_asset')).toBe(true); + expect(migration.evidence.some((e) => e.type === 'reference_relevance_filter')).toBe(true); + }); + + it('智能补全会重排为包装补位,不把参考样例画面直接放进 timeline', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '建立场景', + durationRatio: 1, + intent: '先让观众看清环境', + copyPattern: '纯视觉开场', + }, + ], + }, + slots: [ + { + id: 'slot_scene', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + optional: false, + }, + ], + }; + const closeupAssets: TaggedAsset[] = [ + { + id: 'asset_close_detail', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_detail'], + shotScale: 'close', + durationSec: 8, + confidence: 0.92, + summary: '用户素材只有局部特写', + }, + ]; + const referenceAssets = [ + { + id: 'ref_establishing', + sourceRole: 'current_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['establish_context', 'show_scale'] as TaggedAsset['visualFunctions'], + shotScale: 'wide' as const, + durationSec: 8, + confidence: 0.5, + summary: '参考样例里的建立场景镜头', + sourcePath: '/tmp/reference-establishing.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_smart_fill_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets, + visualGapMode: 'smart_fill', + topic: '旅行记录', + durationSec: 8, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: baseline.directorPlan.shots.map((shot) => ({ + ...shot, + visualFunctions: ['establish_context', 'show_scale'] as typeof shot.visualFunctions, + fallbackStrategies: ['user_asset', 'reference_clip', 'aigc', 'packaging_overlay', 'copy_completion', 'reused_clip'] as typeof shot.fallbackStrategies, + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_smart_fill', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets, + visualGapMode: 'smart_fill', + directorPlan, + topic: '旅行记录', + durationSec: 8, + }); + + expect(migration.visualGapPolicy?.mode).toBe('smart_fill'); + expect(migration.visualCoverage?.referenceClipAllowed).toBe(false); + expect(migration.fills.every((fill) => fill.kind !== 'reference_clip')).toBe(true); + expect(migration.fills.some((fill) => fill.kind === 'packaging_overlay' || fill.kind === 'copy_completion')).toBe(true); + }); + + it('参考桥接只用于可桥接视觉功能,不用参考画面伪造核心结果', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '氛围过渡', + durationRatio: 0.5, + intent: '用短桥接镜头建立节奏', + copyPattern: '纯视觉过渡', + }, + { + role: 'climax' as const, + label: '结果证明', + durationRatio: 0.5, + intent: '展示新主题自己的结果', + copyPattern: '结果说明', + }, + ], + }, + slots: [ + { + id: 'slot_bridge', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + optional: false, + }, + { + id: 'slot_result', + segmentRole: 'climax' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + optional: false, + }, + ], + }; + const assetsWithoutCoverage: TaggedAsset[] = [ + { + id: 'asset_close_detail', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_detail'], + shotScale: 'close', + durationSec: 8, + confidence: 0.92, + summary: '用户素材只有局部特写', + }, + ]; + const referenceAssets = [ + { + id: 'ref_bridge', + sourceRole: 'current_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['bridge_transition', 'establish_context'] as TaggedAsset['visualFunctions'], + shotScale: 'wide' as const, + durationSec: 8, + confidence: 0.5, + summary: '当前样例里的旅行风景氛围桥接镜头', + sourcePath: '/tmp/reference-bridge.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_reference_bridge_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: assetsWithoutCoverage, + referenceAssets, + visualGapMode: 'reference_bridge', + topic: '旅行记录', + durationSec: 8, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: baseline.directorPlan.shots.map((shot, index) => ({ + ...shot, + slotId: index === 0 ? 'slot_bridge' : 'slot_result', + visualFunctions: index === 0 + ? (['bridge_transition'] as typeof shot.visualFunctions) + : (['show_result'] as typeof shot.visualFunctions), + fallbackStrategies: ['user_asset', 'reference_clip', 'aigc', 'packaging_overlay', 'copy_completion', 'reused_clip'] as typeof shot.fallbackStrategies, + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_bridge', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: assetsWithoutCoverage, + referenceAssets, + visualGapMode: 'reference_bridge', + directorPlan, + topic: '旅行记录', + durationSec: 8, + }); + + expect(migration.visualGapPolicy?.referenceClipUse).toBe('bridge_only'); + const referenceFill = migration.fills.find((fill) => fill.kind === 'reference_clip'); + expect(referenceFill).toMatchObject({ slotId: 'slot_bridge', source: '/tmp/reference-bridge.mp4' }); + const referenceItem = migration.timeline.items.find( + (item) => item.source.kind === 'fill_artifact' && item.source.fillArtifactId === referenceFill?.id, + ); + expect((referenceItem?.endSec ?? 0) - (referenceItem?.startSec ?? 0)).toBeLessThanOrEqual( + migration.visualGapPolicy!.maxReferenceClipSec, + ); + expect(referenceItem?.cropPreset).toBe('contain'); + expect(referenceItem?.framePolicy).toBe('reference_viewport'); + const bridgeRemainderFill = migration.fills.find((fill) => fill.slotId === 'slot_bridge_bridge_remainder'); + expect(bridgeRemainderFill?.kind).toBe('reused_clip'); + const resultFill = migration.fills.find((fill) => fill.slotId === 'slot_result'); + expect(resultFill?.kind).not.toBe('reference_clip'); + }); + + it('参考桥接会过滤真人出镜 / 自拍类样例画面,避免样例人物进入成片', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '氛围桥接', + durationRatio: 1, + intent: '用短桥接镜头建立节奏,但不能露出样例人物', + copyPattern: '纯视觉过渡', + }, + ], + }, + slots: [ + { + id: 'slot_bridge', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + requiredVisualFunctions: ['bridge_transition'] as TaggedAsset['visualFunctions'], + optional: false, + }, + ], + }; + const closeupAssets: TaggedAsset[] = [ + { + id: 'asset_user_close', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_detail'], + shotScale: 'close', + durationSec: 8, + confidence: 0.9, + summary: '用户素材只有局部细节,可作为非参考降级承接', + }, + ]; + const personReferenceAssets = [ + { + id: 'ref_current_person', + sourceRole: 'current_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['bridge_transition', 'introduce_subject'] as TaggedAsset['visualFunctions'], + durationSec: 10, + confidence: 0.5, + summary: '当前样例真人出镜口播,人物直面镜头互动', + sourcePath: '/tmp/current-person-reference.mp4', + }, + { + id: 'ref_learned_selfie_vlog', + sourceRole: 'learned_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['bridge_transition', 'establish_context', 'show_emotion'] as TaggedAsset['visualFunctions'], + durationSec: 12, + confidence: 0.7, + summary: '学习样例「Vlog」参考素材:女孩自拍鱼眼 vlog,人物活泼互动,直面镜头', + sourcePath: '/tmp/learned-selfie-reference.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_reference_person_guard_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets: personReferenceAssets, + visualGapMode: 'reference_bridge', + topic: '咖啡豆', + durationSec: 4, + }); + const baseShot = baseline.directorPlan.shots[0]; + const directorPlan = { + ...baseline.directorPlan, + shots: [ + { + ...baseShot, + shotId: 'shot_person_reference_guard', + slotId: 'slot_bridge', + startSec: 0, + endSec: 4, + visualFunctions: ['bridge_transition'] as typeof baseShot.visualFunctions, + fallbackStrategies: ['reference_clip', 'user_asset', 'packaging_overlay', 'copy_completion', 'reused_clip'] as typeof baseShot.fallbackStrategies, + }, + ], + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_person_guard', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets: personReferenceAssets, + visualGapMode: 'reference_bridge', + directorPlan, + topic: '咖啡豆', + durationSec: 4, + }); + + expect(migration.fills.some((fill) => fill.kind === 'reference_clip')).toBe(false); + const filterEvidence = migration.evidence.find((e) => e.type === 'reference_relevance_filter'); + expect(filterEvidence?.ref).toContain('ref_current_person'); + expect(filterEvidence?.ref).toContain('ref_learned_selfie_vlog'); + }); + + it('参考桥接会过滤平台账号 / 作者引流类样例画面,避免样例作者账号进入成片', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '平台账号风险桥接', + durationRatio: 1, + intent: '缺少氛围过渡时仍不能露出样例作者账号或平台搜索页', + copyPattern: '纯视觉过渡', + }, + ], + }, + slots: [ + { + id: 'slot_bridge', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + requiredVisualFunctions: ['bridge_transition'] as TaggedAsset['visualFunctions'], + optional: false, + }, + ], + }; + const closeupAssets: TaggedAsset[] = [ + { + id: 'asset_user_close', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_detail'], + shotScale: 'close', + durationSec: 8, + confidence: 0.9, + summary: '用户素材只有咖啡局部细节,可作为非参考降级承接', + }, + ]; + const accountReferenceAssets = [ + { + id: 'ref_learned_account_endcard', + sourceRole: 'learned_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['bridge_transition', 'show_emotion'] as TaggedAsset['visualFunctions'], + durationSec: 18, + confidence: 0.7, + summary: '学习样例「展示:氛围感开篇 -> 平台引流收尾」参考素材,片尾包含抖音作者账号和搜索引导', + sourcePath: '/tmp/learned-account-endcard-reference.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_reference_account_guard_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets: accountReferenceAssets, + visualGapMode: 'reference_bridge', + topic: '咖啡主题营销视频', + durationSec: 4, + }); + const baseShot = baseline.directorPlan.shots[0]; + const directorPlan = { + ...baseline.directorPlan, + shots: [ + { + ...baseShot, + shotId: 'shot_account_reference_guard', + slotId: 'slot_bridge', + startSec: 0, + endSec: 4, + visualFunctions: ['bridge_transition'] as typeof baseShot.visualFunctions, + fallbackStrategies: ['reference_clip', 'user_asset', 'packaging_overlay', 'copy_completion', 'reused_clip'] as typeof baseShot.fallbackStrategies, + }, + ], + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_account_guard', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets: accountReferenceAssets, + visualGapMode: 'reference_bridge', + directorPlan, + topic: '咖啡主题营销视频', + durationSec: 4, + }); + + expect(migration.fills.some((fill) => fill.kind === 'reference_clip')).toBe(false); + const filterEvidence = migration.evidence.find((e) => e.type === 'reference_relevance_filter'); + expect(filterEvidence?.ref).toContain('ref_learned_account_endcard'); + }); + + it('参考桥接会按缺口位置选择样例视频取段,不固定取样例开头', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '新内容结果', + durationRatio: 0.5, + intent: '先展示新主题自己的结果', + copyPattern: '结果说明', + }, + { + role: 'climax' as const, + label: '氛围桥接', + durationRatio: 0.5, + intent: '用样例方法做短桥接', + copyPattern: '纯视觉过渡', + }, + ], + }, + slots: [ + { + id: 'slot_result', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + optional: false, + }, + { + id: 'slot_bridge', + segmentRole: 'climax' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + optional: false, + }, + ], + }; + const assetsWithoutCoverage: TaggedAsset[] = [ + { + id: 'asset_close_detail', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_detail'], + shotScale: 'close', + durationSec: 8, + confidence: 0.92, + summary: '用户素材只有局部特写', + }, + ]; + const referenceAssets = [ + { + id: 'ref_bridge', + sourceRole: 'current_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['bridge_transition', 'establish_context'] as TaggedAsset['visualFunctions'], + shotScale: 'wide' as const, + durationSec: 12, + confidence: 0.5, + summary: '当前样例里的后段桥接镜头', + sourcePath: '/tmp/reference-late-bridge.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_reference_late_bridge_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: assetsWithoutCoverage, + referenceAssets, + visualGapMode: 'reference_bridge', + topic: '旅行记录', + durationSec: 8, + }); + const lastShotIndex = baseline.directorPlan.shots.length - 1; + const directorPlan = { + ...baseline.directorPlan, + shots: baseline.directorPlan.shots.map((shot, index) => ({ + ...shot, + slotId: index === lastShotIndex ? 'slot_bridge' : 'slot_result', + visualFunctions: index === lastShotIndex + ? (['bridge_transition'] as typeof shot.visualFunctions) + : (['show_result'] as typeof shot.visualFunctions), + fallbackStrategies: ['user_asset', 'reference_clip', 'aigc', 'packaging_overlay', 'copy_completion', 'reused_clip'] as typeof shot.fallbackStrategies, + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_late_bridge', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: assetsWithoutCoverage, + referenceAssets, + visualGapMode: 'reference_bridge', + directorPlan, + topic: '旅行记录', + durationSec: 8, + }); + + const referenceFill = migration.fills.find((fill) => fill.kind === 'reference_clip'); + const referenceItem = migration.timeline.items.find( + (item) => item.source.kind === 'fill_artifact' && item.source.fillArtifactId === referenceFill?.id, + ); + expect(referenceItem?.startSec).toBeGreaterThanOrEqual(4); + expect(referenceItem?.sourceInSec).toBeGreaterThan(0); + expect(referenceItem?.sourceOutSec).toBeLessThanOrEqual(12); + expect(referenceItem?.cropPreset).toBe('contain'); + }); + + it('参考桥接会避开样例片尾平台 end-card 安全区', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'climax' as const, + label: '晚段氛围桥接', + durationRatio: 1, + intent: '晚段缺少氛围承接时可以短桥接,但不能裁到样例片尾平台页', + copyPattern: '纯视觉过渡', + }, + ], + }, + slots: [ + { + id: 'slot_bridge', + segmentRole: 'climax' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + requiredVisualFunctions: ['bridge_transition'] as TaggedAsset['visualFunctions'], + optional: false, + }, + ], + }; + const closeupAssets: TaggedAsset[] = [ + { + id: 'asset_user_close', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_detail'], + shotScale: 'close', + durationSec: 12, + confidence: 0.9, + summary: '用户素材只有咖啡近景,缺少晚段氛围桥接画面', + }, + ]; + const referenceAssets = [ + { + id: 'ref_current_coffee_sample', + sourceRole: 'current_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['bridge_transition', 'show_emotion'] as TaggedAsset['visualFunctions'], + durationSec: 19.875, + confidence: 0.7, + summary: '当前上传样例视频参考素材:咖啡样例.MP4', + sourcePath: '/tmp/current-coffee-sample.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_reference_outro_guard_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets, + visualGapMode: 'reference_bridge', + topic: '咖啡主题营销视频', + durationSec: 17.05, + }); + const baseShot = baseline.directorPlan.shots[0]; + const directorPlan = { + ...baseline.directorPlan, + editConstraints: { + ...baseline.directorPlan.editConstraints, + durationSec: 17.05, + }, + shots: [ + { + ...baseShot, + shotId: 'shot_reference_outro_guard', + slotId: 'slot_bridge', + segmentRole: 'climax' as const, + startSec: 12.833, + endSec: 13.433, + visualFunctions: ['bridge_transition'] as typeof baseShot.visualFunctions, + fallbackStrategies: ['reference_clip', 'user_asset', 'packaging_overlay', 'copy_completion', 'reused_clip'] as typeof baseShot.fallbackStrategies, + }, + ], + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_outro_guard', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets, + visualGapMode: 'reference_bridge', + directorPlan, + topic: '咖啡主题营销视频', + durationSec: 17.05, + }); + + const referenceFill = migration.fills.find((fill) => fill.kind === 'reference_clip'); + const referenceItem = migration.timeline.items.find( + (item) => item.source.kind === 'fill_artifact' && item.source.fillArtifactId === referenceFill?.id, + ); + expect(referenceItem).toBeTruthy(); + expect(referenceItem?.sourceInSec).toBeLessThan(15); + expect(referenceItem?.sourceOutSec).toBeLessThanOrEqual(14.9); + }); + + it('参考桥接遇到 show_scale + show_result 混合功能时拆成样例桥接和证明余段', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'climax' as const, + label: '氛围到结果', + durationRatio: 1, + intent: '先借样例氛围建立尺度,再用新素材或包装承接结果', + copyPattern: '桥接 + 证明', + }, + ], + }, + slots: [ + { + id: 'slot_mixed_result', + segmentRole: 'climax' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + requiredVisualFunctions: ['show_scale', 'show_result'] as TaggedAsset['visualFunctions'], + optional: false, + }, + ], + }; + const assetsWithoutCoverage: TaggedAsset[] = [ + { + id: 'asset_close_detail', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_detail'], + shotScale: 'close', + durationSec: 8, + confidence: 0.92, + summary: '用户素材只有局部特写', + }, + ]; + const referenceAssets = [ + { + id: 'ref_scale_bridge', + sourceRole: 'current_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['show_scale', 'show_emotion'] as TaggedAsset['visualFunctions'], + shotScale: 'wide' as const, + durationSec: 10, + confidence: 0.5, + summary: '当前样例里的环境尺度桥接镜头', + sourcePath: '/tmp/reference-scale-bridge.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_reference_mixed_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: assetsWithoutCoverage, + referenceAssets, + visualGapMode: 'reference_bridge', + topic: '咖啡豆', + durationSec: 5, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: baseline.directorPlan.shots.map((shot) => ({ + ...shot, + slotId: 'slot_mixed_result', + visualFunctions: ['show_scale', 'show_result'] as typeof shot.visualFunctions, + fallbackStrategies: ['user_asset', 'reference_clip', 'aigc', 'packaging_overlay', 'copy_completion', 'reused_clip'] as typeof shot.fallbackStrategies, + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_mixed', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: assetsWithoutCoverage, + referenceAssets, + visualGapMode: 'reference_bridge', + directorPlan, + topic: '咖啡豆', + durationSec: 5, + }); + + const referenceFill = migration.fills.find((fill) => fill.kind === 'reference_clip'); + expect(referenceFill).toMatchObject({ + slotId: 'slot_mixed_result_bridge', + source: '/tmp/reference-scale-bridge.mp4', + }); + const referenceItem = migration.timeline.items.find( + (item) => item.source.kind === 'fill_artifact' && item.source.fillArtifactId === referenceFill?.id, + ); + expect(referenceItem).toBeTruthy(); + expect((referenceItem?.endSec ?? 0) - (referenceItem?.startSec ?? 0)).toBeLessThanOrEqual( + migration.visualGapPolicy!.maxReferenceClipSec, + ); + expect(referenceItem?.cropPreset).toBe('contain'); + const remainderItem = migration.timeline.items.find((item) => item.id.includes('_mixed_remainder')); + expect(remainderItem).toBeTruthy(); + const remainderFillId = remainderItem?.source.kind === 'fill_artifact' ? remainderItem.source.fillArtifactId : ''; + const remainderFill = migration.fills.find((fill) => fill.id === remainderFillId); + expect(remainderFill?.kind).not.toBe('reference_clip'); + expect(remainderItem?.startSec).toBe(referenceItem?.endSec); + }); + + it('参考桥接会强制总占比、同源去重,并避开样例片头标题窗口', () => { + const coverageBlueprint = { + ...sampleBlueprint, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '连续氛围桥接', + durationRatio: 1, + intent: '借样例的风景节奏做短桥接,但不能复制样例画面主体', + copyPattern: '纯视觉桥接', + }, + ], + }, + slots: [ + { + id: 'slot_bridge', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + requiredVisualFunctions: ['bridge_transition'] as TaggedAsset['visualFunctions'], + optional: false, + }, + ], + }; + const closeupAssets: TaggedAsset[] = [ + { + id: 'asset_user_close', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_detail'], + shotScale: 'close', + durationSec: 18, + confidence: 0.88, + summary: '用户素材:局部细节画面,可作为非参考替代段', + }, + ]; + const referenceAssets = [ + { + id: 'ref_title_then_landscape', + sourceRole: 'current_sample' as const, + mediaType: 'video' as const, + assetTags: ['b_roll'] as TaggedAsset['assetTags'], + visualFunctions: ['bridge_transition', 'establish_context'] as TaggedAsset['visualFunctions'], + shotScale: 'wide' as const, + durationSec: 12, + confidence: 0.7, + summary: '样例片头有 VACATION 大字标题动画,3 秒后是稳定风景远景', + highlightWindows: [ + { startSec: 3, endSec: 8, score: 0.95, reason: '稳定风景远景,已避开片头标题动画' }, + ], + sourcePath: '/tmp/reference-title-then-landscape.mp4', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_reference_budget_guard_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets, + visualGapMode: 'reference_bridge', + topic: '旅行记录', + durationSec: 20, + }); + const baseShot = baseline.directorPlan.shots[0]; + const directorPlan = { + ...baseline.directorPlan, + shots: Array.from({ length: 4 }, (_, index) => ({ + ...baseShot, + shotId: `shot_ref_budget_${index + 1}`, + slotId: 'slot_bridge', + startSec: index * 5, + endSec: (index + 1) * 5, + visualFunctions: ['bridge_transition'] as typeof baseShot.visualFunctions, + fallbackStrategies: ['user_asset', 'reference_clip', 'aigc', 'packaging_overlay', 'copy_completion', 'reused_clip'] as typeof baseShot.fallbackStrategies, + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_reference_budget_guard', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: coverageBlueprint, + assets: closeupAssets, + referenceAssets, + visualGapMode: 'reference_bridge', + directorPlan, + topic: '旅行记录', + durationSec: 20, + }); + + const fillById = new Map(migration.fills.map((fill) => [fill.id, fill])); + const referenceItems = migration.timeline.items.filter((item) => { + const fillId = item.source.kind === 'fill_artifact' ? item.source.fillArtifactId : ''; + return fillById.get(fillId)?.kind === 'reference_clip'; + }); + const referenceSec = referenceItems.reduce((sum, item) => sum + item.endSec - item.startSec, 0); + expect(referenceItems).toHaveLength(1); + expect(referenceSec).toBeLessThanOrEqual(migration.timeline.durationSec * 0.12 + 0.001); + expect(referenceItems[0].sourceInSec).toBeGreaterThanOrEqual(3); + expect(referenceItems[0].cropPreset).toBe('contain'); + expect( + migration.timeline.items.filter((item) => item.source.kind === 'user_asset' && item.source.assetId === 'asset_user_close').length, + ).toBeGreaterThan(0); + expect(migration.evidence.some((e) => e.type === 'reference_clip_budget_guard')).toBe(true); + }); + + it('未指定目标时长时按素材质量自适应,不再硬拉到 30 秒', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_adaptive_duration', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '便携咖啡杯', + }); + + expect(migration.timeline.durationSec).toBeGreaterThanOrEqual(8); + expect(migration.timeline.durationSec).toBeLessThan(30); + expect(migration.evidence.some((e) => e.type === 'duration' && e.detail.includes('自适应'))).toBe(true); + }); + + it('生成的文字卡补全保持单段,不再被 shot slicing 重复切出', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_text_card_single', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: [ + { + id: 'slot_text', + segmentRole: 'setup', + requiredAssetTypes: ['text_card'], + optional: false, + }, + ], + }, + assets: [], + topic: '便携咖啡杯', + durationSec: 20, + }); + + const textFill = migration.fills.find((fill) => fill.slotId === 'slot_text'); + expect(textFill?.source.startsWith('textcard://')).toBe(true); + expect(textFill?.displayText).toBeTruthy(); + const textItems = migration.timeline.items.filter( + (item) => item.source.kind === 'fill_artifact' && item.source.fillArtifactId === textFill?.id, + ); + expect(textItems).toHaveLength(1); + }); + + it('开头 3 秒内不会连续重复同一张文字卡', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_no_duplicate_opening_card', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: [ + { + id: 'slot_hook', + segmentRole: 'hook', + requiredAssetTypes: ['text_card'], + optional: false, + }, + ], + }, + assets: [ + { id: 'asset_scene', mediaType: 'image', assetTags: ['b_roll'], confidence: 0.9, summary: '清澈湖水风景' }, + ], + topic: '周末旅行', + durationSec: 12, + }); + + const earlyCards = migration.timeline.items + .filter((item) => item.startSec <= 3.2 && item.source.kind === 'fill_artifact') + .map((item) => migration.fills.find((fill) => fill.id === (item.source as { fillArtifactId: string }).fillArtifactId)?.displayText) + .filter(Boolean); + expect(earlyCards.length).toBe(new Set(earlyCards).size); + }); + + it('最终可见文案不泄漏内部 slot / shot / debug 占位信息', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_public_copy', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: [ + { + id: 'slot-001', + segmentRole: 'hook', + requiredAssetTypes: ['text_card'], + optional: false, + }, + { + id: 'slot-002', + segmentRole: 'setup', + requiredAssetTypes: ['talking_head'], + optional: false, + }, + ], + }, + assets: [], + topic: '咖啡豆种草视频', + sellingPoints: ['从新鲜烘焙开始'], + durationSec: 12, + }); + + const visibleText = [ + ...migration.script.map((line) => line.text), + ...migration.fills.map((fill) => fill.displayText ?? decodeTextCardSource(fill.source)), + ].join('\n'); + const textCardSources = migration.fills + .filter((fill) => fill.source.startsWith('textcard://')) + .map((fill) => decodeTextCardSource(fill.source)) + .join('\n'); + + expect(visibleText).not.toMatch(/slot-|shot-|补全|copy_completion|packaging_overlay|用文字卡补全|展开主体内容|反常识疑问句/); + expect(textCardSources).not.toMatch(/slot-|shot-|补全/); + expect(migration.fills.some((fill) => fill.debugLabel?.includes('slot-001'))).toBe(true); + }); + + it('样例库 packaging pattern 会决定文字卡样式和动画 preset', () => { + const learnedPattern: LearnedSamplePatternT = LearnedSamplePattern.parse({ + id: 'pattern_title_slide', + scope: 'global', + sourceSampleId: sampleBlueprint.sourceSampleId, + name: '标题条滑动样例', + summary: '使用顶部色块标题条和滑动转场的样例', + videoGenre: sampleBlueprint.videoGenre, + tags: ['title-bar'], + reusablePatternName: '标题条转场卡', + formula: '用色块标题条承接段落,再滑动进入下一镜', + source: { + filename: 'sample.mp4', + durationSec: 12, + aspectRatio: '1080:1920', + shotCount: 6, + }, + segments: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + ...segment, + watchingPurpose: segment.intent, + })), + pacing: { + durationSec: 12, + shotCount: 6, + avgShotSec: 2, + cutDensity: 'medium', + peakAt: 0.6, + beatHints: [], + }, + packaging: { + subtitleDensity: 'medium', + titleBarStyle: '顶部色块标题条', + stickerUsage: '无', + transitionStyle: '滑动转场', + coverStyle: '简洁封面', + }, + slotNeeds: [], + evidence: [], + rationale: '', + createdAt: '2026-05-26T00:00:00.000Z', + updatedAt: '2026-05-26T00:00:00.000Z', + }); + + const migration = runRuleBasedMigration({ + projectId: 'proj_card_pattern', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: [ + { + id: 'slot_text', + segmentRole: 'setup', + requiredAssetTypes: ['text_card'], + optional: false, + }, + ], + }, + assets: [], + learnedPatterns: [learnedPattern], + topic: '便携咖啡杯', + durationSec: 12, + }); + + const cardItem = migration.timeline.items.find((item) => item.cardStylePreset); + expect(cardItem?.cardStylePreset).toBe('title_bar'); + expect(cardItem?.cardAnimationPreset).toBe('slide_left'); + expect(migration.evidence.some((e) => e.type === 'card_style' && e.ref === learnedPattern.id)).toBe(true); + }); + + it('目标 BGM 不同时优先检索全局相似 BGM 样例,并把切镜吸附到其 rhythmProfile', () => { + const beatStep = 60 / 128; + const detectedBeatGrid = { + bpm: 128, + offsetSec: 0, + beatsSec: Array.from({ length: 28 }, (_, i) => Number((i * beatStep).toFixed(3))).filter((t) => t <= 12), + source: 'detected' as const, + confidence: 'high' as const, + rationale: 'test target BGM grid', + }; + const fastMusic = { + hasAudio: true, + durationSec: 12, + bpm: 128, + beatCount: 26, + beatStability: 0.92, + onsetDensity: 2.05, + energyShape: 'rising' as const, + peakAt: 0.55, + confidence: 'high' as const, + tags: ['vlog', 'density:high', 'bpm:128'], + }; + const basePattern = { + scope: 'global' as const, + name: '快节奏旅行卡点', + summary: '相似 BGM 的旅行快切样例', + videoGenre: sampleBlueprint.videoGenre, + tags: ['travel', 'high', 'bpm:128'], + reusablePatternName: '旅行快切:强拍换景', + formula: '强 hook -> 连续换景 -> 结尾收束', + source: { + filename: 'fast-travel.mp4', + durationSec: 12, + aspectRatio: '1080:1920', + shotCount: 8, + }, + segments: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + ...segment, + watchingPurpose: segment.intent, + })), + pacing: { + durationSec: 12, + shotCount: 8, + avgShotSec: 1.5, + cutDensity: 'high' as const, + peakAt: 0.55, + beatHints: ['cut on strong beat'], + }, + packaging: sampleBlueprint.packagingStructure, + bgmSyncPattern: { + beatPlacement: 'cut on downbeat', + syncStrategy: '每 2-4 拍切换一个旅行画面,高能段加密。', + confidence: 'high' as const, + limitations: [], + }, + musicFingerprint: fastMusic, + rhythmProfile: { + id: 'rhythm_fast_bgm', + source: 'global_sample' as const, + durationSec: 12, + music: fastMusic, + shotPattern: { + cutDensity: 'high', + shotCount: 8, + avgShotSec: 1.5, + peakAt: 0.55, + cutEveryBeats: 3, + phraseLengthBeats: 8, + }, + events: [ + { + eventType: 'cut' as const, + timeSec: 1.406, + relativeTime: 0.117, + beatIndex: 3, + phraseIndex: 0, + nearestBeatSec: 1.406, + offsetMs: 0, + strength: 'strong' as const, + description: '开场强拍切主视觉', + }, + { + eventType: 'cut' as const, + timeSec: 2.813, + relativeTime: 0.234, + beatIndex: 6, + phraseIndex: 0, + nearestBeatSec: 2.813, + offsetMs: 0, + strength: 'medium' as const, + description: '旅行画面换景', + }, + { + eventType: 'cut' as const, + timeSec: 4.219, + relativeTime: 0.352, + beatIndex: 9, + phraseIndex: 1, + nearestBeatSec: 4.219, + offsetMs: 0, + strength: 'medium' as const, + description: '高能段加密切换', + }, + ], + cutIntervalsSec: [1.406, 1.407, 1.406, 7.781], + captionStrategy: '字幕在切镜后跟随出现', + strategySummary: '快节奏旅行 BGM 每 3 拍换景。', + }, + learnedDimensions: { + scriptStructure: { + formula: '强 hook -> 连续换景 -> 结尾收束', + segmentCount: sampleBlueprint.scriptStructure.segments.length, + segments: [], + notes: [], + }, + shotRhythm: { + durationSec: 12, + shotCount: 8, + avgShotSec: 1.5, + cutDensity: 'high' as const, + peakAt: 0.55, + beatHints: ['cut on strong beat'], + rhythmNotes: [], + }, + subtitleStyle: { + density: 'medium', + placement: 'lower_third', + typography: 'clean', + animation: 'cut', + notes: [], + }, + visualPackaging: { + overlayStyle: 'clean', + notes: [], + }, + transitions: { + style: 'cut', + frequency: '高频切换', + notableTransitions: [], + executableTechniques: [], + }, + bgmSync: { + hasAudio: true, + beatHints: ['cut on strong beat'], + syncStrategy: '目标 BGM 用强拍换景。', + confidence: 'high' as const, + limitations: [], + }, + }, + slotNeeds: [], + evidence: [], + rationale: '', + createdAt: '2026-05-31T00:00:00.000Z', + updatedAt: '2026-05-31T00:00:00.000Z', + }; + const fastRhythmPattern: LearnedSamplePatternT = LearnedSamplePattern.parse({ + ...basePattern, + id: 'pattern_fast_bgm', + sourceSampleId: 'global_fast_sample', + }); + const slowPattern: LearnedSamplePatternT = LearnedSamplePattern.parse({ + ...basePattern, + id: 'pattern_slow_bgm', + sourceSampleId: 'global_slow_sample', + reusablePatternName: '慢节奏铺垫', + pacing: { ...basePattern.pacing, cutDensity: 'low' as const, avgShotSec: 4, shotCount: 3 }, + musicFingerprint: { + ...fastMusic, + bpm: 92, + beatCount: 18, + onsetDensity: 0.75, + energyShape: 'steady' as const, + tags: ['bpm:92'], + }, + rhythmProfile: undefined, + updatedAt: '2026-05-30T00:00:00.000Z', + }); + + const migration = runRuleBasedMigration({ + projectId: 'proj_rhythm_transfer', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + learnedPatterns: [slowPattern, fastRhythmPattern], + topic: '九寨沟旅行', + durationSec: 12, + detectedBeatGrid, + }); + + expect(migration.timeline.rhythmPlan?.strategy).toBe('global_music_match'); + expect(migration.timeline.rhythmPlan?.matchedGlobalPattern?.patternId).toBe('pattern_fast_bgm'); + expect(migration.evidence.some((e) => e.type === 'rhythm_transfer' && e.ref === 'pattern_fast_bgm')).toBe(true); + expect(migration.evidence.some((e) => e.type === 'pacing_envelope' && e.ref === 'pattern_fast_bgm')).toBe(true); + const envelope = migration.timeline.rhythmPlan?.pacingEnvelope; + expect(envelope?.source).toBe('blended'); + expect(envelope?.sampleWeight).toBeGreaterThan(envelope?.globalWeight ?? 1); + expect(envelope?.globalWeight).toBeGreaterThan(0); + expect(envelope?.phases.map((phase) => phase.role)).toEqual([ + 'setup', + 'accelerate', + 'hold', + 'climax', + 'payoff', + ]); + expect(migration.decisions.some((decision) => decision.chosen === 'pacing_envelope_blend')).toBe(true); + const visualItems = migration.timeline.items.filter((item) => item.track !== 'audio'); + expect(visualItems.every((item) => item.rhythmAnchor)).toBe(true); + expect(visualItems.some((item) => item.rhythmAnchor?.source === 'global_match')).toBe(true); + }); + + it('素材重复护栏会打断同一真实素材的长时间连续复用', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_repetition_guard', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + id: `slot_${segment.role}`, + segmentRole: segment.role, + requiredAssetTypes: ['b_roll'], + optional: false, + })), + }, + assets: [ + { + id: 'asset_only_visual', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 18, + confidence: 0.92, + summary: '唯一可用素材', + }, + ], + topic: '手冲咖啡', + sellingPoints: ['香气稳定', '出品干净'], + durationSec: 18, + }); + + expect(migration.evidence.some((e) => e.type === 'asset_repetition_guard' && e.detail.includes('触发'))).toBe( + true, + ); + expect(migration.fills.some((fill) => fill.id.startsWith('fill_guard_'))).toBe(true); + expect(migration.fills.filter((fill) => fill.id.startsWith('fill_guard_')).every((fill) => fill.kind === 'reused_clip')).toBe(true); + expect(longestRepeatedAssetRun(migration.timeline.items)).toBeLessThanOrEqual(5.1); + expect(validateMigrationPlan(migration).ok).toBe(true); + }); + + it('提供外部 DirectorPlan 时,migration 按该计划生成 shotRef 时间线', () => { + const baseline = runRuleBasedMigration({ + projectId: 'proj_director_override', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + const directorPlan = { + ...baseline.directorPlan, + id: 'dp_external_llm', + storyArc: { + opening: 'LLM 导演开场', + setup: 'LLM 导演铺垫', + progression: 'LLM 导演推进', + turn: 'LLM 导演转折', + payoff: 'LLM 导演收束', + emotionalCurve: ['抓停', '理解', '相信'], + }, + shots: baseline.directorPlan.shots.map((shot, index) => ({ + ...shot, + shotId: `llm_shot_${index + 1}`, + storyBeat: `LLM 情节 ${index + 1}`, + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_director_override', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + directorPlan, + }); + + const visualItems = migration.timeline.items.filter((item) => item.track !== 'audio'); + expect(migration.directorPlan.id).toBe('dp_external_llm'); + expect(migration.directorPlan.storyArc.turn).toBe('LLM 导演转折'); + expect(visualItems.map((item) => item.shotRef)).toEqual(directorPlan.shots.map((shot) => shot.shotId)); + expect(migration.evidence.some((e) => e.type === 'director_plan_source')).toBe(true); + expect(validateMigrationPlan(migration).ok).toBe(true); + }); + + it('同一视频素材的 source window 默认连续消耗,避免跳切回放', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_source_cursor', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + id: `slot_${segment.role}`, + segmentRole: segment.role, + requiredAssetTypes: ['b_roll'], + optional: false, + })), + }, + assets: [ + { + id: 'asset_long_a', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 30, + confidence: 0.92, + summary: '长素材 A', + }, + { + id: 'asset_long_b', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 30, + confidence: 0.92, + summary: '长素材 B', + }, + ], + topic: '手冲咖啡', + durationSec: 14, + }); + + const windowsByAsset = new Map>(); + for (const item of migration.timeline.items) { + if (item.source.kind !== 'user_asset' || item.sourceInSec == null || item.sourceOutSec == null) continue; + const list = windowsByAsset.get(item.source.assetId) ?? []; + list.push({ sourceInSec: item.sourceInSec, sourceOutSec: item.sourceOutSec }); + windowsByAsset.set(item.source.assetId, list); + } + + for (const windows of windowsByAsset.values()) { + for (let i = 1; i < windows.length; i++) { + expect(windows[i].sourceInSec).toBeGreaterThanOrEqual(windows[i - 1].sourceOutSec - 0.05); + } + } + }); + + it('外部 clip scoring 和 safe crop 信号会驱动取段窗口与裁切 preset', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_clip_score_signals', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + id: `slot_${segment.role}`, + segmentRole: segment.role, + requiredAssetTypes: ['b_roll'], + optional: false, + })), + }, + assets: [ + { + id: 'asset_scored_video', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 12, + confidence: 0.82, + summary: '一条有高光动作的视频素材', + }, + ], + topic: '手冲咖啡', + durationSec: 6, + migrationControls: { + signals: { + clipScores: [ + { + assetId: 'asset_scored_video', + score: 0.91, + reasons: ['稳定', '主体清晰'], + highlightWindows: [{ startSec: 5, endSec: 8.2, score: 0.94, reason: '动作高光' }], + }, + ], + safeCropHints: [ + { + assetId: 'asset_scored_video', + preset: 'closeup', + reason: 'product', + confidence: 0.88, + }, + ], + }, + }, + }); + + const firstAssetItem = migration.timeline.items.find( + (item) => item.source.kind === 'user_asset' && item.source.assetId === 'asset_scored_video', + ); + expect(firstAssetItem?.sourceInSec).toBe(5); + expect(firstAssetItem?.cropPreset).toBe('closeup'); + expect(migration.evidence.some((e) => e.type === 'analysis_signals' && e.detail.includes('clip scores=1'))).toBe(true); + }); + + it('低素材时把 1-2 条长视频扩展成多个虚拟镜头', () => { + const lowMaterialAssets: TaggedAsset[] = [ + { + id: 'asset_jar', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['introduce_subject', 'show_action', 'show_detail'], + storyRoles: ['opening_hook', 'action', 'detail'], + shotScale: 'medium', + aspectRatio: '9:16', + durationSec: 14, + confidence: 0.92, + summary: '竖屏长镜头:人物把食材放进玻璃罐', + }, + { + id: 'asset_extract', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['show_action', 'show_detail', 'show_result'], + storyRoles: ['action', 'proof', 'payoff'], + shotScale: 'close', + aspectRatio: '9:16', + durationSec: 11, + confidence: 0.92, + summary: '竖屏长镜头:咖啡机萃取液体进入杯子', + }, + ]; + + const migration = runRuleBasedMigration({ + projectId: 'proj_low_material_virtual_shots', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + id: `slot_${segment.role}`, + segmentRole: segment.role, + requiredAssetTypes: ['b_roll'], + optional: false, + })), + }, + assets: lowMaterialAssets, + topic: '咖啡豆日常短片', + durationSec: 14, + }); + + const visualItems = migration.timeline.items.filter((item) => item.track !== 'audio'); + const userVideoItems = visualItems.filter((item) => item.source.kind === 'user_asset'); + expect(validateMigrationPlan(migration).ok).toBe(true); + expect(migration.evidence.some((e) => e.type === 'low_material_expansion')).toBe(true); + expect(migration.directorPlan.shots.length).toBeGreaterThan(sampleBlueprint.scriptStructure.segments.length); + expect(visualItems.length).toBeGreaterThanOrEqual(6); + expect(new Set(userVideoItems.map((item) => item.cropPreset)).size).toBeGreaterThanOrEqual(3); + expect(userVideoItems.some((item) => item.motionPreset !== 'static')).toBe(true); + + const windowsByAsset = new Map>(); + for (const item of userVideoItems) { + if (item.source.kind !== 'user_asset' || item.sourceInSec == null || item.sourceOutSec == null) continue; + const list = windowsByAsset.get(item.source.assetId) ?? []; + list.push({ sourceInSec: item.sourceInSec, sourceOutSec: item.sourceOutSec }); + windowsByAsset.set(item.source.assetId, list); + } + expect([...windowsByAsset.values()].some((windows) => windows.length > 1)).toBe(true); + }); + + it('生成更适合首屏抓停的短 hook,并把 selected hook 用到首个镜头文案', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_hook_quality', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香', '锁住每一口咖啡香'], + durationSec: 12, + }); + + const selected = migration.creativeBrief.hookCandidates.find( + (hook) => hook.id === migration.creativeBrief.selectedHookId, + ); + expect(selected?.text.length).toBeLessThanOrEqual(18); + expect(selected?.score.total).toBeGreaterThanOrEqual(86); + expect(migration.directorPlan.shots[0].screenTextIntent).toContain(selected?.text); + expect(migration.script.find((line) => line.segmentRole === 'hook')?.screenText).toBe(selected?.text); + }); + + it('样例无字幕(captionStyle=none / subtitleDensity=sparse)时不给镜头强加字幕', () => { + const noSubtitleBlueprint = { + ...sampleBlueprint, + videoGenre: 'vlog' as const, + scriptStructure: { + segments: sampleBlueprint.scriptStructure.segments.map((seg) => ({ + ...seg, + captionStyle: { placement: 'none' as const, bilingualLike: false }, + })), + }, + packagingStructure: { + ...(sampleBlueprint.packagingStructure ?? { + titleBarStyle: '', + stickerUsage: '', + transitionStyle: '', + coverStyle: '', + }), + subtitleDensity: 'sparse' as const, + }, + // 纯画面 vlog:槽位只要 b_roll,不引入 talking_head / text_card 这类结构性文字需求。 + slots: sampleBlueprint.slots.map((slot) => ({ ...slot, requiredAssetTypes: ['b_roll' as const] })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_no_subtitle', + sampleId: noSubtitleBlueprint.sourceSampleId, + blueprint: noSubtitleBlueprint, + assets, + topic: '旅行 vlog 开头', + sellingPoints: [], + durationSec: 12, + }); + + // 所有镜头都应纯靠画面表达,不带任何上屏文字 / 字幕 copyMode。 + expect(migration.directorPlan.shots.every((shot) => shot.copyMode === 'none' && shot.copyRequired === false)).toBe(true); + // 脚本里不应出现任何 screenText(上屏字幕)。 + expect(migration.script.every((line) => (line.screenText ?? '') === '')).toBe(true); + }); + + it('用户没有明确卖点/brief 时,默认按样例视频的故事弧线迁移', () => { + const storyBlueprint = { + ...sampleBlueprint, + videoGenre: 'narrative' as const, + scriptStructure: { + segments: [ + { + role: 'hook' as const, + label: '意外开场', + durationRatio: 0.2, + intent: '先出现一个反常细节,让观众想知道发生了什么', + copyPattern: '结果先给但不解释原因', + }, + { + role: 'setup' as const, + label: '人物处境', + durationRatio: 0.2, + intent: '交代主角当下的普通状态', + copyPattern: '日常场景铺垫', + }, + { + role: 'develop' as const, + label: '行动尝试', + durationRatio: 0.2, + intent: '主角开始尝试解决问题', + copyPattern: '连续动作推进', + }, + { + role: 'climax' as const, + label: '反转证明', + durationRatio: 0.2, + intent: '揭示一开始异常细节背后的原因', + copyPattern: '前后呼应反转', + }, + { + role: 'closing' as const, + label: '余味收束', + durationRatio: 0.2, + intent: '用一个安静镜头留下情绪余味', + copyPattern: '轻 CTA / 情绪金句', + }, + ], + }, + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_sample_story_default', + sampleId: storyBlueprint.sourceSampleId, + blueprint: storyBlueprint, + assets: [ + { + id: 'asset_scene', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 10, + confidence: 0.9, + summary: '旅行中的日常场景和主角行动', + }, + ], + topic: '周末独自旅行', + durationSec: 12, + }); + + expect(migration.directorPlan.storyArc.opening).toContain('意外开场'); + expect(migration.directorPlan.storyArc.setup).toContain('人物处境'); + expect(migration.directorPlan.storyArc.turn).toContain('反转证明'); + expect(migration.directorPlan.storyArc.payoff).toContain('余味收束'); + expect(migration.directorPlan.shots.map((shot) => shot.storyBeat)).toEqual([ + '意外开场', + '人物处境', + '行动尝试', + '反转证明', + '余味收束', + ]); + expect(migration.rationale).toContain('样例故事弧线'); + }); + + it('外部 LLM DirectorPlan 偏通用时,无明确需求会被样例故事弧线归一化', () => { + const baseline = runRuleBasedMigration({ + projectId: 'proj_generic_llm_baseline', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '周末独自旅行', + durationSec: 12, + }); + const genericDirectorPlan = { + ...baseline.directorPlan, + storyArc: { + opening: '通用种草开场', + setup: '卖点铺垫', + progression: '卖点展开', + turn: '卖点证明', + payoff: '购买转化', + emotionalCurve: ['买点', '理解', '转化'], + }, + shots: baseline.directorPlan.shots.map((shot) => ({ + ...shot, + storyBeat: '通用卖点', + purpose: '泛化带货表达', + })), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_generic_llm_normalized', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '周末独自旅行', + durationSec: 12, + directorPlan: genericDirectorPlan, + }); + + expect(migration.directorPlan.storyArc.opening).toContain('迁移样例故事开场'); + expect(migration.directorPlan.shots[0].storyBeat).toBe('反差开场'); + expect(migration.directorPlan.shots.every((shot) => shot.storyBeat !== '通用卖点')).toBe(true); + expect(migration.directorPlan.rationale).toContain('强制按样例故事弧线归一化'); + }); + + it('素材匹配优先覆盖 storyFunction,缺少 proof/payoff 时不把同一 b-roll 当完整故事闭环', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_story_roles', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + id: `slot_${segment.role}`, + segmentRole: segment.role, + requiredAssetTypes: ['b_roll'], + optional: false, + })), + }, + assets: [ + { + id: 'asset_context', + mediaType: 'video', + assetTags: ['b_roll'], + storyRoles: ['context', 'mood'], + narrativeUse: 'context', + visualMood: ['安静'], + durationSec: 8, + confidence: 0.88, + summary: '安静湖面和环境铺垫', + }, + { + id: 'asset_action', + mediaType: 'video', + assetTags: ['b_roll'], + storyRoles: ['opening_hook', 'action'], + narrativeUse: 'action', + visualMood: ['行动'], + durationSec: 8, + confidence: 0.88, + summary: '人物走动和旅行行动', + }, + { + id: 'asset_proof', + mediaType: 'video', + assetTags: ['b_roll'], + storyRoles: ['proof', 'payoff'], + narrativeUse: 'proof', + visualMood: ['结果'], + durationSec: 8, + confidence: 0.88, + summary: '到达目的地后的结果画面', + }, + ], + topic: '周末独自旅行', + durationSec: 12, + }); + + const sourcesByFunction = migration.timeline.items + .filter((item) => item.track !== 'audio') + .map((item) => { + const shot = migration.directorPlan.shots.find((s) => s.shotId === item.shotRef); + return { storyFunction: shot?.storyFunction, source: item.source }; + }); + + expect(sourcesByFunction.some((entry) => entry.storyFunction === 'opening_hook' && entry.source.kind === 'user_asset' && entry.source.assetId === 'asset_action')).toBe(true); + expect(sourcesByFunction.some((entry) => entry.storyFunction === 'context' && entry.source.kind === 'user_asset' && entry.source.assetId === 'asset_context')).toBe(true); + expect(sourcesByFunction.some((entry) => (entry.storyFunction === 'proof' || entry.storyFunction === 'payoff') && entry.source.kind === 'user_asset' && entry.source.assetId === 'asset_proof')).toBe(true); + }); + + it('15-20s 短片会压低单素材和单场景簇占比,并为旧素材补 story 标签', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_short_cluster_guard', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + id: `slot_${segment.role}`, + segmentRole: segment.role, + requiredAssetTypes: ['b_roll'], + optional: false, + })), + }, + assets: [ + { + id: 'asset_lake_1', + mediaType: 'image', + assetTags: ['b_roll'], + confidence: 0.9, + summary: '清澈蓝绿色湖水和天空树枝,适合风景开场', + }, + { + id: 'asset_lake_2', + mediaType: 'image', + assetTags: ['b_roll'], + confidence: 0.9, + summary: '蓝色湖面水景,画面很治愈', + }, + { + id: 'asset_person', + mediaType: 'image', + assetTags: ['b_roll'], + confidence: 0.88, + summary: '旅行人物自拍和游客近景', + }, + { + id: 'asset_waterfall', + mediaType: 'image', + assetTags: ['b_roll'], + confidence: 0.88, + summary: '瀑布和到达目的地的高光结果画面', + }, + ], + topic: '周末旅行', + durationSec: 18, + }); + + const visualItems = migration.timeline.items.filter((item) => item.track !== 'audio'); + const secondsByAsset = new Map(); + for (const item of visualItems) { + if (item.source.kind !== 'user_asset') continue; + secondsByAsset.set(item.source.assetId, (secondsByAsset.get(item.source.assetId) ?? 0) + item.endSec - item.startSec); + } + expect(Math.max(...secondsByAsset.values())).toBeLessThanOrEqual(18 * 0.35 + 0.01); + expect(migration.evidence.some((e) => e.type === 'asset_tag' && e.detail.includes('补齐 storyRoles'))).toBe(true); + }); + + it('timeline 生成 BGM beatGrid,并把视觉切点吸附到 beat 上', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_bgm_beat_grid', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + ...assets, + { + id: 'asset_bgm', + mediaType: 'audio', + assetTags: [], + durationSec: 20, + confidence: 1, + summary: '轻快旅行 BGM', + }, + ], + topic: '周末旅行', + durationSec: 18, + }); + + expect(migration.timeline.beatGrid?.beatsSec.length).toBeGreaterThan(10); + const beats = migration.timeline.beatGrid?.beatsSec ?? []; + const visualItems = migration.timeline.items.filter((item) => item.track !== 'audio'); + for (const item of visualItems.slice(1)) { + const nearest = Math.min(...beats.map((beat) => Math.abs(beat - item.startSec))); + expect(nearest).toBeLessThanOrEqual(0.001); + } + expect(migration.evidence.some((e) => e.type === 'bgm' && e.detail.includes('BGM 卡点网格'))).toBe(true); + }); + + it('按镜头职责调度素材,opening/detail/proof 不再只按标签机械复用同一素材', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_asset_roles', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + id: `slot_${segment.role}`, + segmentRole: segment.role, + requiredAssetTypes: ['b_roll'], + optional: false, + })), + }, + assets: [ + { + id: 'asset_opening', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 8, + confidence: 0.85, + summary: '开场冲突 第一眼抓人 咖啡豆倒入罐子', + }, + { + id: 'asset_detail', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 8, + confidence: 0.85, + summary: '细节特写 新鲜咖啡豆油脂和香气 texture', + }, + { + id: 'asset_proof', + mediaType: 'video', + assetTags: ['b_roll'], + durationSec: 8, + confidence: 0.85, + summary: '证明结果 萃取出稳定咖啡液 payoff', + }, + ], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香', '锁住每一口咖啡香'], + durationSec: 12, + }); + + const hookItem = migration.timeline.items.find((item) => item.shotRef === migration.directorPlan.shots[0].shotId); + const detailItem = migration.timeline.items.find((item) => { + const shot = migration.directorPlan.shots.find((s) => s.shotId === item.shotRef); + return shot?.visualRole === 'detail'; + }); + const proofItem = migration.timeline.items.find((item) => { + const shot = migration.directorPlan.shots.find((s) => s.shotId === item.shotRef); + return shot?.visualRole === 'proof'; + }); + + expect(hookItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_opening' }); + expect(detailItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_detail' }); + expect(proofItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_proof' }); + }); + + it('text_card 缺口有真实素材时优先复用素材而不是生成全屏白卡', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_text_card_reuse_visual', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: [ + { + id: 'slot_text', + segmentRole: 'setup', + requiredAssetTypes: ['text_card'], + optional: false, + }, + ], + }, + assets: [ + { + id: 'asset_scene', + mediaType: 'image', + assetTags: ['b_roll'], + confidence: 0.9, + summary: '咖啡冲煮场景和产品氛围图', + }, + ], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香'], + durationSec: 12, + }); + + const fillForTextSlot = migration.fills.find((fill) => fill.slotId === 'slot_text'); + expect(fillForTextSlot?.kind).toBe('reused_clip'); + expect(fillForTextSlot?.source).toContain('asset_scene'); + expect(migration.fills.filter((fill) => fill.slotId === 'slot_text').every((fill) => fill.kind !== 'reference_clip')).toBe(true); + }); + + it('短 hook 命中真实视频时使用安全微运镜增强首屏观感', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_hook_micro_motion', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + id: 'asset_hook_talking', + mediaType: 'video', + assetTags: ['talking_head', 'b_roll'], + durationSec: 8, + confidence: 0.92, + summary: '真人出镜展示产品开场', + }, + { + id: 'asset_product', + mediaType: 'video', + assetTags: ['product_closeup'], + durationSec: 5, + confidence: 0.9, + summary: '产品特写', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时', '单手开盖'], + durationSec: 12, + }); + + const firstVisual = migration.timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec)[0]; + expect(firstVisual?.source).toEqual({ kind: 'user_asset', assetId: 'asset_hook_talking' }); + expect(firstVisual?.motionPreset).toBe('push_in'); + }); + + it('旅行类 hook 优先选择 wide / show_scale 风景素材,而不是普通 POV 地面镜头', () => { + const travelBlueprint = { + ...sampleBlueprint, + videoGenre: 'vlog' as const, + slots: [ + { + id: 'slot_hook', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + requiredVisualFunctions: ['establish_context'] as TaggedAsset['visualFunctions'], + optional: false, + }, + ], + }; + const travelAssets: TaggedAsset[] = [ + { + id: 'asset_pov_ground', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['establish_context', 'show_action'], + shotScale: 'medium', + visualMood: ['human'], + visualClusterId: 'scene:pov_ground', + durationSec: 8, + confidence: 0.96, + summary: '第一视角脚下路面走路 POV,随手拍的普通地面镜头', + }, + { + id: 'asset_wide_lake', + mediaType: 'video', + assetTags: ['b_roll'], + storyRoles: ['opening_hook', 'context'], + visualFunctions: ['establish_context', 'show_scale'], + shotScale: 'wide', + aspectRatio: '16:9', + visualMood: ['scenic', 'calm'], + visualClusterId: 'scene:blue_water', + durationSec: 8, + confidence: 0.9, + summary: '九寨沟蓝色湖水和山林全景,开阔风景远景', + }, + ]; + const baseline = runRuleBasedMigration({ + projectId: 'proj_travel_hook_base', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: travelBlueprint, + assets: travelAssets, + topic: '九寨沟旅行 vlog', + durationSec: 12, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: baseline.directorPlan.shots.map((shot, index) => index === 0 + ? { + ...shot, + slotId: 'slot_hook', + segmentRole: 'hook' as const, + storyFunction: 'opening_hook' as const, + visualRole: 'establishing' as const, + shotScale: 'wide' as const, + visualFunctions: ['establish_context' as const], + assetNeed: ['b_roll' as const], + preferredAssetIds: ['asset_pov_ground'], + fallbackStrategies: ['user_asset' as const, 'copy_completion' as const], + visualDirection: '九寨沟旅行 vlog 首屏必须先给开阔风景,而不是脚下 POV。', + } + : shot), + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_travel_hook', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: travelBlueprint, + directorPlan, + assets: travelAssets, + topic: '九寨沟旅行 vlog', + durationSec: 12, + }); + + const firstVisual = migration.timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec)[0]; + expect(firstVisual?.source).toEqual({ kind: 'user_asset', assetId: 'asset_wide_lake' }); + expect(firstVisual?.framePolicy).toBe('landscape_viewport'); + }); + + it('closing/payoff 全屏文字卡会优先改成真实背景 + 下三分之一文案,并压低文字卡占比', () => { + const textBudgetBlueprint = { + ...sampleBlueprint, + slots: [ + { + id: 'slot_hook', + segmentRole: 'hook' as const, + requiredAssetTypes: ['b_roll'] as TaggedAsset['assetTags'], + optional: false, + }, + ], + }; + const baseline = runRuleBasedMigration({ + projectId: 'proj_text_card_budget_base', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: textBudgetBlueprint, + assets: [], + topic: '九寨沟旅行 vlog', + durationSec: 12, + }); + const hookBase = baseline.directorPlan.shots.find((shot) => shot.segmentRole === 'hook') ?? baseline.directorPlan.shots[0]; + const closingBase = baseline.directorPlan.shots.find((shot) => shot.segmentRole === 'closing') ?? baseline.directorPlan.shots[0]; + const directorPlan = { + ...baseline.directorPlan, + shots: [ + { + ...hookBase, + shotId: 'shot_hook_real', + slotId: undefined, + startSec: 0, + endSec: 2, + preferredAssetIds: ['asset_hook_real'], + assetNeed: ['b_roll' as const], + visualRole: 'person_in_scene' as const, + visualFunctions: ['introduce_subject' as const], + shotScale: 'medium' as const, + fallbackStrategies: ['user_asset' as const, 'copy_completion' as const], + copyMode: 'screen_text' as const, + }, + { + ...closingBase, + shotId: 'shot_closing_card', + slotId: undefined, + startSec: 2, + endSec: 12, + segmentRole: 'closing' as const, + storyFunction: 'payoff' as const, + visualRole: 'cta_card' as const, + visualFunctions: ['call_to_action' as const], + preferredAssetIds: [], + assetNeed: ['text_card' as const], + fallbackStrategies: ['copy_completion' as const], + copyMode: 'title_card' as const, + copyRequired: true, + screenTextIntent: '下一站,就去九寨沟', + }, + ], + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_text_card_budget', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: textBudgetBlueprint, + directorPlan, + assets: [ + { + id: 'asset_hook_real', + mediaType: 'image', + assetTags: ['b_roll'], + storyRoles: ['opening_hook'], + visualFunctions: ['introduce_subject'], + shotScale: 'medium', + visualMood: ['scenic'], + confidence: 0.88, + summary: '九寨沟入口游客准备出发的开场画面', + }, + { + id: 'asset_lake_bg', + mediaType: 'image', + assetTags: ['b_roll'], + storyRoles: ['opening_hook', 'payoff'], + visualFunctions: ['establish_context', 'show_scale', 'show_result'], + shotScale: 'wide', + visualMood: ['scenic', 'payoff'], + visualClusterId: 'scene:blue_water', + confidence: 0.92, + summary: '九寨沟蓝色湖水远景,可作为结尾收束背景', + }, + ], + topic: '九寨沟旅行 vlog', + durationSec: 12, + }); + + const closingItem = migration.timeline.items.find((item) => item.shotRef === 'shot_closing_card'); + expect(closingItem?.source).toEqual({ kind: 'user_asset', assetId: 'asset_lake_bg' }); + expect(closingItem?.framePolicy).toBe('real_background_lower_third'); + expect(closingItem?.overlayText).toBeTruthy(); + const closingStoryboard = migration.storyboard.find((item) => item.shotId === 'shot_closing_card'); + expect(closingStoryboard?.screenText).toBeTruthy(); + expect(generatedCardDurationSec(migration)).toBeLessThanOrEqual(12 * 0.12); + expect(migration.evidence.some((e) => e.type === 'text_card_budget')).toBe(true); + expect(migration.qcReport.issues.map((issue) => issue.id)).not.toContain('qc_textcard_ratio'); + }); + + it('hook 包装文字卡有真实素材时会迁移成实景背景 overlay,而不是全屏卡片', () => { + const baseline = runRuleBasedMigration({ + projectId: 'proj_hook_overlay_base', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香'], + durationSec: 8, + }); + const hookBase = baseline.directorPlan.shots.find((shot) => shot.segmentRole === 'hook') ?? baseline.directorPlan.shots[0]; + const directorPlan = { + ...baseline.directorPlan, + editConstraints: { + ...baseline.directorPlan.editConstraints, + durationSec: 8, + }, + shots: [ + { + ...hookBase, + shotId: 'shot_hook_packaging', + slotId: 'missing_hook_packaging', + startSec: 0, + endSec: 2.4, + segmentRole: 'hook' as const, + storyFunction: 'opening_hook' as const, + visualRole: 'transition_card' as const, + visualFunctions: ['bridge_transition' as const], + assetNeed: ['text_card' as const], + preferredAssetIds: [], + fallbackStrategies: ['packaging_overlay' as const], + copyMode: 'title_card' as const, + copyRequired: true, + screenTextIntent: '别急着买,先看每天一杯', + }, + ], + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_hook_overlay', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + directorPlan, + assets: [ + { + id: 'asset_coffee_table', + mediaType: 'image', + assetTags: ['b_roll'], + storyRoles: ['opening_hook'], + visualFunctions: ['introduce_subject', 'establish_context'], + shotScale: 'medium', + visualMood: ['warm', 'coffee'], + confidence: 0.9, + summary: '暖调柔光下的咖啡桌面真实素材', + }, + ], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香'], + durationSec: 8, + }); + + const firstVisual = migration.timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec)[0]; + expect(firstVisual?.source).toEqual({ kind: 'user_asset', assetId: 'asset_coffee_table' }); + expect(firstVisual?.framePolicy).toBe('real_background_lower_third'); + expect(firstVisual?.overlayText).toBeTruthy(); + expect(firstVisual?.overlayText).not.toMatch(/generated-card|missing_hook|补全/); + expect(migration.evidence.some((e) => e.type === 'text_card_budget')).toBe(true); + }); + + it('QC 首屏返工在低素材场景可复用已使用真实素材做短桥接', () => { + const baseline = runRuleBasedMigration({ + projectId: 'proj_qc_reuse_used_base', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时', '单手开盖'], + durationSec: 12, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: [ + { + ...baseline.directorPlan.shots[0], + slotId: 'missing_hook_slot', + preferredAssetIds: [], + visualFunctions: ['call_to_action' as const], + assetNeed: ['talking_head' as const], + fallbackStrategies: ['reference_clip' as const, 'copy_completion' as const], + }, + { + ...baseline.directorPlan.shots[1], + slotId: 'slot_solution', + preferredAssetIds: ['asset_product'], + assetNeed: ['product_closeup' as const], + }, + ], + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_qc_reuse_used', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + directorPlan, + assets: [ + { + id: 'asset_product', + mediaType: 'video', + assetTags: ['product_closeup', 'b_roll'], + visualFunctions: ['introduce_subject', 'show_detail'], + durationSec: 5, + confidence: 0.9, + summary: '唯一可用的产品真实视频', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时', '单手开盖'], + durationSec: 12, + }); + + const visualItems = migration.timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec); + expect(visualItems[0]?.source).toEqual({ kind: 'user_asset', assetId: 'asset_product' }); + expect(visualItems[0]!.endSec - visualItems[0]!.startSec).toBeLessThanOrEqual(1.5); + expect(visualItems.some((item) => item.id.includes('qc_remainder') && item.source.kind === 'fill_artifact')).toBe(true); + expect(migration.evidence.some((e) => e.type === 'qc_auto_revision')).toBe(true); + expect(migration.qcReport.issues.map((issue) => issue.id)).not.toContain('qc_first_screen_asset'); + }); + + it('产品类视频无真实素材时默认使用短视频包装 preset,而不是 sticker_pop 白底 NEW 卡', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_social_packaging', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + videoGenre: 'product', + packagingStructure: { + subtitleDensity: 'dense', + titleBarStyle: '大字卖点', + stickerUsage: '关键词高亮', + transitionStyle: '快切弹出', + coverStyle: '商品主视觉', + }, + slots: [ + { + id: 'slot_text', + segmentRole: 'setup', + requiredAssetTypes: ['text_card'], + optional: false, + }, + ], + }, + assets: [], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香'], + durationSec: 12, + }); + + const cardItem = migration.timeline.items.find((item) => item.cardStylePreset); + expect(cardItem?.cardStylePreset).toBe('social_punch'); + expect(cardItem?.cardAnimationPreset).toBe('snap_pop'); + expect(migration.directorPlan.editConstraints.packagingPreset).toBe('punchy_social'); + }); + + it('用户指定 shot/slot 素材时优先执行 assignment,即使标签不是最佳匹配', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_locked_assignment', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + id: 'asset_auto_best', + mediaType: 'image', + assetTags: ['product_closeup'], + confidence: 0.95, + summary: '清晰商品特写', + }, + { + id: 'asset_forced_hook', + mediaType: 'image', + assetTags: ['b_roll'], + confidence: 0.72, + summary: '用户指定的开场环境画面', + }, + ], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香'], + durationSec: 12, + migrationControls: { + locks: { + shotAssetAssignments: { + hook: 'asset_forced_hook', + }, + }, + }, + }); + + const firstVisual = migration.timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec)[0]; + expect(firstVisual?.source).toEqual({ kind: 'user_asset', assetId: 'asset_forced_hook' }); + expect(migration.evidence.some((e) => e.type === 'migration_locks' && e.detail.includes('asset_forced_hook'))).toBe(true); + }); + + it('QC 自动返工会把首屏文字卡替换成真实视觉素材', () => { + const blueprint = { + ...sampleBlueprint, + slots: [ + { + id: 'slot_hook', + segmentRole: 'hook' as const, + requiredAssetTypes: ['talking_head' as const], + optional: false, + }, + ], + }; + const baseline = runRuleBasedMigration({ + projectId: 'proj_qc_auto_revision_base', + sampleId: sampleBlueprint.sourceSampleId, + blueprint, + assets: [], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香'], + durationSec: 12, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: [ + { + ...baseline.directorPlan.shots[0], + slotId: 'missing_hook_slot', + visualFunctions: ['call_to_action' as const], + assetNeed: ['talking_head' as const], + fallbackStrategies: ['reference_clip' as const, 'copy_completion' as const], + }, + ], + }; + const migration = runRuleBasedMigration({ + projectId: 'proj_qc_auto_revision', + sampleId: sampleBlueprint.sourceSampleId, + blueprint, + directorPlan, + assets: [ + { + id: 'asset_real_opening', + mediaType: 'image', + assetTags: ['b_roll'], + visualFunctions: ['introduce_subject', 'establish_context'], + confidence: 0.86, + summary: '清晰稳定的产品氛围开场画面', + }, + { + id: 'asset_extra_detail', + mediaType: 'image', + assetTags: ['product_closeup'], + confidence: 0.8, + summary: '额外的产品细节图', + }, + ], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香'], + durationSec: 12, + }); + + const firstVisual = migration.timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec)[0]; + expect(firstVisual?.source).toEqual({ kind: 'user_asset', assetId: 'asset_real_opening' }); + expect(firstVisual?.cardStylePreset).toBeUndefined(); + expect(migration.evidence.some((e) => e.type === 'qc_auto_revision')).toBe(true); + expect(migration.qcReport.issues.map((issue) => issue.id)).not.toContain('qc_first_screen_asset'); + }); + + it('成片级 QC 会识别首屏 hook、重复素材和文字卡占比风险', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_output_qc', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: { + ...sampleBlueprint, + slots: sampleBlueprint.scriptStructure.segments.map((segment) => ({ + id: `slot_${segment.role}`, + segmentRole: segment.role, + requiredAssetTypes: ['text_card'], + optional: false, + })), + }, + assets: [], + topic: '咖啡豆种草视频', + sellingPoints: ['刚烘好的鲜香'], + durationSec: 16, + }); + + const issueIds = migration.qcReport.issues.map((issue) => issue.id); + expect(issueIds).toContain('qc_textcard_ratio'); + expect(issueIds).toContain('qc_first_screen_asset'); + expect(migration.qcReport.scores.outputWatchability).toBeLessThan(80); + expect(migration.revisionPlan.items.some((item) => item.issueId === 'qc_textcard_ratio')).toBe(true); + }); + + it('proof/payoff 缺少专门素材时暴露为素材预算问题,而不是普通 b-roll 硬顶', () => { + const baseline = runRuleBasedMigration({ + projectId: 'proj_story_budget_base', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + const directorPlan = { + ...baseline.directorPlan, + shots: [ + { + ...baseline.directorPlan.shots[0], + storyFunction: 'proof' as const, + visualFunctions: ['show_result' as const], + preferredAssetIds: ['asset_generic_broll'], + assetNeed: ['b_roll' as const], + }, + { + ...baseline.directorPlan.shots[1], + storyFunction: 'payoff' as const, + visualFunctions: ['call_to_action' as const], + preferredAssetIds: ['asset_generic_broll'], + assetNeed: ['b_roll' as const], + }, + ], + }; + + const migration = runRuleBasedMigration({ + projectId: 'proj_story_budget', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + directorPlan, + assets: [ + { + id: 'asset_generic_broll', + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: ['establish_context'], + durationSec: 8, + confidence: 0.8, + summary: '普通产品环境空镜,没有实测证明或结果收束', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + + expect(migration.visualGapPolicy?.adaptationMode).toBe('needs_capture'); + expect(migration.qcReport.issues.map((issue) => issue.id)).toContain('qc_story_closure'); + expect(migration.evidence.some((e) => e.type === 'story_budget_gap')).toBe(true); + }); +}); + +function longestRepeatedAssetRun(items: Array<{ track: string; startSec: number; endSec: number; source: { kind: string; assetId?: string } }>): number { + const visualItems = items.filter((item) => item.track !== 'audio').sort((a, b) => a.startSec - b.startSec); + let currentKey = ''; + let currentStart = 0; + let currentEnd = 0; + let longest = 0; + + for (const item of visualItems) { + const key = item.source.kind === 'user_asset' ? item.source.assetId ?? '' : `non_asset:${item.source.kind}`; + if (!key.startsWith('asset_')) { + longest = Math.max(longest, currentEnd - currentStart); + currentKey = ''; + currentStart = item.endSec; + currentEnd = item.endSec; + continue; + } + + if (key !== currentKey || Math.abs(item.startSec - currentEnd) > 0.05) { + longest = Math.max(longest, currentEnd - currentStart); + currentKey = key; + currentStart = item.startSec; + } + currentEnd = item.endSec; + } + + return Math.max(longest, currentEnd - currentStart); +} + +function generatedCardDurationSec(migration: ReturnType): number { + return migration.timeline.items.reduce((sum, item) => { + const source = item.source; + if (!('fillArtifactId' in source)) return sum; + const fill = migration.fills.find((candidate) => candidate.id === source.fillArtifactId); + return fill?.source.startsWith('textcard://') ? sum + item.endSec - item.startSec : sum; + }, 0); +} + +function decodeTextCardSource(source: string): string { + if (!source.startsWith('textcard://')) return source; + const raw = source.slice('textcard://'.length); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} diff --git a/apps/api/src/core/__tests__/schemas.test.ts b/apps/api/src/core/__tests__/schemas.test.ts index 9a41d3a..80379e7 100644 --- a/apps/api/src/core/__tests__/schemas.test.ts +++ b/apps/api/src/core/__tests__/schemas.test.ts @@ -4,8 +4,10 @@ import { validateTimeline, validateTimelineTraceability, } from '../validate'; +import { DirectorArtifacts } from '../director'; import { sampleBlueprint } from '../mocks/sample-blueprint'; import { sampleAssets, sampleFills, sampleTimeline } from '../mocks/sample-timeline'; +import { runRuleBasedMigration } from '../migration'; describe('VideoStructureBlueprint schema', () => { it('接受合法的样例蓝图 mock', () => { @@ -69,3 +71,28 @@ describe('Timeline schema 与可追溯性', () => { expect(r.errors.join('\n')).toContain('does_not_exist'); }); }); + +describe('Director Workflow schemas', () => { + it('规则迁移产出的导演层 artifacts 可被 schema 校验', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_test', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: sampleAssets, + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 30, + }); + + const parsed = DirectorArtifacts.safeParse({ + creativeBrief: migration.creativeBrief, + beatMap: migration.beatMap, + shotList: migration.shotList, + assetPlan: migration.assetPlan, + editDecisionList: migration.editDecisionList, + qcReport: migration.qcReport, + revisionPlan: migration.revisionPlan, + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/apps/api/src/core/__tests__/versions.test.ts b/apps/api/src/core/__tests__/versions.test.ts new file mode 100644 index 0000000..b17e0f7 --- /dev/null +++ b/apps/api/src/core/__tests__/versions.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { applyBlueprintPatch } from '../applyPatch'; +import { sampleBlueprint } from '../mocks/sample-blueprint'; +import type { TaggedAsset } from '../slot'; +import { validateBlueprint, validateMigrationPlan } from '../validate'; +import { VERSION_PRESETS, generateVersions } from '../versions'; + +const assets: TaggedAsset[] = [ + { id: 'a1', mediaType: 'video', assetTags: ['product_closeup'], durationSec: 5, confidence: 0.9, summary: '特写' }, +]; + +describe('applyBlueprintPatch', () => { + it('set + scale 生效,且段落比例归一化到 1', () => { + const fast = VERSION_PRESETS.find((p) => p.id === 'fast')!; + const out = applyBlueprintPatch(sampleBlueprint, fast.patch); + + expect(out.rhythmStructure.cutDensity).toBe('high'); + expect(out.rhythmStructure.avgShotSec).toBeCloseTo(sampleBlueprint.rhythmStructure.avgShotSec * 0.7, 2); + const sum = out.scriptStructure.segments.reduce((a, s) => a + s.durationRatio, 0); + expect(sum).toBeCloseTo(1, 2); + expect(validateBlueprint(out).ok).toBe(true); + // 不改原对象(base 的 avgShotSec 不应被改动) + expect(sampleBlueprint.rhythmStructure.avgShotSec).not.toBe(out.rhythmStructure.avgShotSec); + }); + + it('未识别 path 被忽略、不报错', () => { + const out = applyBlueprintPatch(sampleBlueprint, { + origin: 'nl', + ops: [{ path: 'nonsense.path', op: 'set', value: 1 }], + }); + expect(validateBlueprint(out).ok).toBe(true); + }); +}); + +describe('generateVersions', () => { + it('产出 3 个差异明确且各自合法的版本', () => { + const variants = generateVersions({ + projectId: 'p', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: '测试主题', + durationSec: 30, + }); + + expect(variants).toHaveLength(3); + const byId = Object.fromEntries(variants.map((v) => [v.id, v])); + expect(byId.fast.blueprint.rhythmStructure.cutDensity).toBe('high'); + expect(byId.cinematic.blueprint.rhythmStructure.cutDensity).toBe('low'); + // 高节奏 vs 高质感的平均镜头时长应不同(差异明确) + expect(byId.fast.blueprint.rhythmStructure.avgShotSec).toBeLessThan( + byId.cinematic.blueprint.rhythmStructure.avgShotSec, + ); + for (const v of variants) { + expect(validateBlueprint(v.blueprint).ok).toBe(true); + expect(validateMigrationPlan(v.migration).ok).toBe(true); + } + }); +}); diff --git a/apps/api/src/core/applyPatch.ts b/apps/api/src/core/applyPatch.ts new file mode 100644 index 0000000..a6149e5 --- /dev/null +++ b/apps/api/src/core/applyPatch.ts @@ -0,0 +1,70 @@ +import type { VideoStructureBlueprint } from './blueprint'; +import type { CutDensity, SubtitleDensity } from './enums'; +import type { BlueprintPatch, BlueprintPatchOp } from './patch'; + +function round(n: number): number { + return Number(n.toFixed(3)); +} +function clamp01(n: number): number { + return Math.max(0, Math.min(1, n)); +} + +/** + * 应用一个 BlueprintPatch,返回新蓝图(不改原对象)。 + * 人工调参 / 多版本预设 / NL 编辑统一走这里(见 DESIGN §5.6)。 + * 支持的 path: + * - `rhythmStructure.cutDensity` (set) + * - `rhythmStructure.avgShotSec` (set | scale) + * - `rhythmStructure.peakAt` (set) + * - `packagingStructure.subtitleDensity` (set) + * - `segment..durationRatio` (scale,按 role 命中) + * 未识别的 path 忽略。最后对段落 durationRatio 归一化到和为 1。 + */ +export function applyBlueprintPatch( + bp: VideoStructureBlueprint, + patch: BlueprintPatch, +): VideoStructureBlueprint { + const next = structuredClone(bp); + for (const op of patch.ops) applyOp(next, op); + normalizeRatios(next); + return next; +} + +function applyOp(bp: VideoStructureBlueprint, op: BlueprintPatchOp): void { + const { path, op: kind, value } = op; + + if (path === 'rhythmStructure.cutDensity' && kind === 'set') { + bp.rhythmStructure.cutDensity = value as CutDensity; + return; + } + if (path === 'rhythmStructure.peakAt' && kind === 'set') { + bp.rhythmStructure.peakAt = clamp01(Number(value)); + return; + } + if (path === 'rhythmStructure.avgShotSec') { + if (kind === 'scale') bp.rhythmStructure.avgShotSec = round(bp.rhythmStructure.avgShotSec * Number(value)); + else if (kind === 'set') bp.rhythmStructure.avgShotSec = Number(value); + return; + } + if (path === 'packagingStructure.subtitleDensity' && kind === 'set' && bp.packagingStructure) { + bp.packagingStructure.subtitleDensity = value as SubtitleDensity; + return; + } + const seg = path.match(/^segment\.([a-z_]+)\.durationRatio$/); + if (seg && kind === 'scale') { + for (const s of bp.scriptStructure.segments) { + if (s.role === seg[1]) s.durationRatio = Math.max(0.01, s.durationRatio * Number(value)); + } + } + // 未识别 path:忽略(保持健壮) +} + +function normalizeRatios(bp: VideoStructureBlueprint): void { + const segs = bp.scriptStructure.segments; + const sum = segs.reduce((a, s) => a + s.durationRatio, 0); + if (sum <= 0 || segs.length === 0) return; + for (const s of segs) s.durationRatio = round(s.durationRatio / sum); + // 修正四舍五入漂移,让和恰好为 1 + const drift = round(1 - segs.reduce((a, s) => a + s.durationRatio, 0)); + segs[segs.length - 1].durationRatio = round(segs[segs.length - 1].durationRatio + drift); +} diff --git a/apps/api/src/core/blueprint.ts b/apps/api/src/core/blueprint.ts index 16a7853..5f10239 100644 --- a/apps/api/src/core/blueprint.ts +++ b/apps/api/src/core/blueprint.ts @@ -2,10 +2,37 @@ import { z } from 'zod'; import { CutDensity, SegmentRole, SubtitleDensity, VideoGenre } from './enums'; import { Evidence } from './explain'; import { StructureSlot } from './slot'; +import { MotionPreset, TransitionPreset } from './timeline'; /** durationRatio 之和允许的误差。 */ export const RATIO_TOLERANCE = 0.05; +export const VisualRole = z.enum([ + 'establishing', + 'person_in_scene', + 'detail', + 'action', + 'proof', + 'b_roll', + 'transition_card', + 'cta_card', +]); +export type VisualRole = z.infer; + +export const ShotScale = z.enum(['wide', 'medium', 'close', 'macro']); +export type ShotScale = z.infer; + +export const CaptionPlacement = z.enum(['none', 'top', 'center', 'lower_third', 'bottom']); +export type CaptionPlacement = z.infer; + +export const CaptionStyleIntent = z.object({ + placement: CaptionPlacement.default('bottom'), + density: SubtitleDensity.optional(), + bilingualLike: z.boolean().default(false), + notes: z.string().optional(), +}); +export type CaptionStyleIntent = z.infer; + /** 脚本 / 段落结构中的单个段落。 */ export const Segment = z.object({ role: SegmentRole, @@ -15,6 +42,16 @@ export const Segment = z.object({ intent: z.string(), /** 文案"模式",而非样例原文。 */ copyPattern: z.string(), + /** 从样例抽象出的镜头职责,供迁移阶段选择素材与运镜。 */ + visualRole: VisualRole.optional(), + /** 样例该段的主要景别倾向。 */ + shotScale: ShotScale.optional(), + /** 样例该段可迁移的运镜倾向。 */ + motionIntent: MotionPreset.optional(), + /** 样例该段可迁移的转场倾向。 */ + transitionIntent: TransitionPreset.optional(), + /** 样例字幕 / 上屏文字的布局意图。 */ + captionStyle: CaptionStyleIntent.optional(), }); export type Segment = z.infer; @@ -37,7 +74,16 @@ export const RhythmStructure = z.object({ bgmBeatHints: z.array(z.string()).default([]), /** 可选的逻辑镜头边界。 */ shots: z - .array(z.object({ startSec: z.number().min(0), endSec: z.number().min(0) })) + .array( + z.object({ + startSec: z.number().min(0), + endSec: z.number().min(0), + visualRole: VisualRole.optional(), + shotScale: ShotScale.optional(), + motionIntent: MotionPreset.optional(), + transitionIntent: TransitionPreset.optional(), + }), + ) .optional(), }); export type RhythmStructure = z.infer; diff --git a/apps/api/src/core/director.ts b/apps/api/src/core/director.ts new file mode 100644 index 0000000..1db7c85 --- /dev/null +++ b/apps/api/src/core/director.ts @@ -0,0 +1,1808 @@ +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { ShotScale, VisualRole, type Segment, type VideoStructureBlueprint } from './blueprint'; +import { AssetTag, MigrationIntent, ReferenceClipMode, SegmentRole, StoryFunction, VisualFunction } from './enums'; +import { Evidence } from './explain'; +import type { LearnedSamplePattern } from './sampleLearning'; +import type { SlotMatch, ScriptLine, StoryboardItem } from './migration'; +import type { FillArtifact, Gap, TaggedAsset } from './slot'; +import { + CardAnimationPreset, + CardStylePreset, + MotionPreset, + type Timeline, + type TimelineItem, + TransitionPreset, +} from './timeline'; + +const Score10 = z.number().min(0).max(10); +const Score100 = z.number().min(0).max(100); +const MAX_FULL_SCREEN_TEXT_CARD_RATIO = 0.12; + +export const HookScore = z.object({ + clarity: Score10, + curiosity: Score10, + audienceFit: Score10, + visualPotential: Score10, + total: Score100, +}); +export type HookScore = z.infer; + +export const HookCandidate = z.object({ + id: z.string(), + type: z.string(), + text: z.string(), + score: HookScore, + rationale: z.string(), +}); +export type HookCandidate = z.infer; + +export const CreativeBrief = z.object({ + topic: z.string(), + audience: z.string(), + platform: z.string(), + goal: z.string(), + durationSec: z.number().positive(), + corePromise: z.string(), + tone: z.string(), + hookCandidates: z.array(HookCandidate).min(1), + selectedHookId: z.string(), + hookRationale: z.string(), + evidence: z.array(Evidence).default([]), + rationale: z.string().default(''), +}); +export type CreativeBrief = z.infer; + +export const SampleStoryBeat = z.object({ + segmentRole: SegmentRole, + label: z.string(), + intent: z.string(), + copyPattern: z.string(), + storyFunction: StoryFunction, +}); +export type SampleStoryBeat = z.infer; + +export const SampleStoryArc = z.object({ + premise: z.string(), + openingQuestion: z.string(), + setupSituation: z.string(), + progressionPattern: z.string(), + turnOrProof: z.string(), + payoff: z.string(), + emotionalCurve: z.array(z.string()).default([]), + beatFunctions: z.array(SampleStoryBeat).min(1), +}); +export type SampleStoryArc = z.infer; + +export const Beat = z + .object({ + id: z.string(), + segmentRole: SegmentRole, + startSec: z.number().min(0), + endSec: z.number().min(0), + purpose: z.string(), + visualChange: z.string(), + screenText: z.string(), + audioCue: z.string().optional(), + }) + .refine((b) => b.endSec > b.startSec, { message: 'endSec 必须大于 startSec' }); +export type Beat = z.infer; + +export const BeatMap = z.object({ + durationSec: z.number().positive(), + beats: z.array(Beat).min(1), + rationale: z.string().default(''), +}); +export type BeatMap = z.infer; + +export const SourcePreference = z.enum([ + 'user_asset', + 'structure_reframe', + 'copy_completion', + 'text_card', + 'packaging_overlay', + 'reference_clip', + 'reused_clip', + 'aigc', +]); +export type SourcePreference = z.infer; + +export const ShotCopyMode = z.enum(['none', 'subtitle', 'caption', 'voiceover', 'screen_text', 'title_card']); +export type ShotCopyMode = z.infer; + +export const DirectorCropPreset = z.enum(['center', 'top', 'bottom', 'left', 'right', 'closeup']); +export type DirectorCropPreset = z.infer; + +export const DirectorPlanShot = z + .object({ + shotId: z.string(), + segmentRole: SegmentRole, + slotId: z.string().optional(), + startSec: z.number().min(0), + endSec: z.number().min(0), + purpose: z.string(), + storyBeat: z.string(), + storyFunction: StoryFunction.default('mood'), + visualDirection: z.string(), + visualRole: VisualRole.default('b_roll'), + shotScale: ShotScale.default('medium'), + visualFunctions: z.array(VisualFunction).min(1).default(['show_action']), + communicationIntent: z.string().default('用画面和剪辑完成表达。'), + copyMode: ShotCopyMode.default('subtitle'), + copyRequired: z.boolean().default(true), + copyPurpose: z.string().optional(), + screenTextIntent: z.string(), + assetNeed: z.array(AssetTag).min(1), + preferredAssetIds: z.array(z.string()).default([]), + fallbackStrategies: z.array(SourcePreference).min(1), + motionPreset: MotionPreset, + cropPreset: DirectorCropPreset, + transitionPreset: TransitionPreset, + mustShow: z.string(), + }) + .refine((s) => s.endSec > s.startSec, { message: 'endSec 必须大于 startSec' }); +export type DirectorPlanShot = z.infer; + +export const AssetBudget = z.object({ + reusableAssetCount: z.number().int().min(0), + usableVisualSec: z.number().min(0), + repeatedVisualRisk: z.enum(['low', 'medium', 'high']), + notes: z.array(z.string()).default([]), +}); +export type AssetBudget = z.infer; + +export const DirectorEditConstraints = z.object({ + durationSec: z.number().positive(), + targetShotSec: z.number().positive(), + cutDensity: z.string(), + subtitleDensity: z.string().optional(), + packagingPreset: z.enum(['punchy_social', 'clean_product', 'lifestyle_story']), + cardStylePreset: CardStylePreset, + cardAnimationPreset: CardAnimationPreset, + maxContinuousAssetSec: z.number().positive(), +}); +export type DirectorEditConstraints = z.infer; + +export const DirectorFillPolicy = z.object({ + preferredOrder: z.array(SourcePreference).min(1), + rationale: z.string(), +}); +export type DirectorFillPolicy = z.infer; + +export const DirectorPlan = z.object({ + id: z.string(), + creativeBrief: CreativeBrief, + sampleStoryArc: SampleStoryArc.optional(), + selectedPatternId: z.string().optional(), + storyArc: z.object({ + opening: z.string(), + setup: z.string().optional(), + progression: z.string(), + turn: z.string().optional(), + payoff: z.string(), + emotionalCurve: z.array(z.string()).default([]), + }), + assetBudget: AssetBudget, + editConstraints: DirectorEditConstraints, + fillPolicy: DirectorFillPolicy, + shots: z.array(DirectorPlanShot).min(1), + evidence: z.array(Evidence).default([]), + rationale: z.string().default(''), +}); +export type DirectorPlan = z.infer; + +export const Shot = z.object({ + shotId: z.string(), + beatId: z.string(), + segmentRole: SegmentRole, + purpose: z.string(), + subject: z.string(), + action: z.string(), + composition: z.string(), + motion: z.string(), + assetNeed: z.array(AssetTag).min(1), + sourcePreference: SourcePreference, +}); +export type Shot = z.infer; + +export const ShotList = z.object({ + shots: z.array(Shot).min(1), + rationale: z.string().default(''), +}); +export type ShotList = z.infer; + +export const AssetPlanItem = z.object({ + shotId: z.string(), + slotId: z.string().optional(), + requiredTags: z.array(AssetTag).min(1), + matchedAssetId: z.string().optional(), + gapId: z.string().optional(), + fillArtifactId: z.string().optional(), + fillStrategy: z.string().optional(), + reason: z.string(), +}); +export type AssetPlanItem = z.infer; + +export const AssetPlan = z.object({ + items: z.array(AssetPlanItem).min(1), + rationale: z.string().default(''), +}); +export type AssetPlan = z.infer; + +export const EditDecision = z + .object({ + id: z.string(), + beatId: z.string().optional(), + shotId: z.string().optional(), + startSec: z.number().min(0), + endSec: z.number().min(0), + sourceKind: z.string(), + sourceRef: z.string().optional(), + subtitle: z.string(), + overlay: z.string().optional(), + transition: z.string().optional(), + reason: z.string(), + }) + .refine((d) => d.endSec > d.startSec, { message: 'endSec 必须大于 startSec' }); +export type EditDecision = z.infer; + +export const EditDecisionList = z.object({ + decisions: z.array(EditDecision).min(1), + rationale: z.string().default(''), +}); +export type EditDecisionList = z.infer; + +export const QCSeverity = z.enum(['low', 'medium', 'high']); +export type QCSeverity = z.infer; + +export const QCModule = z.enum([ + 'hook', + 'structure', + 'pacing', + 'shot', + 'asset', + 'caption', + 'watchability', + 'render', + 'explainability', + 'risk', +]); +export type QCModule = z.infer; + +export const QCScores = z.object({ + hookStrength: Score100, + structureClarity: Score100, + storyClosure: Score100, + pacingChange: Score100, + shotExecutability: Score100, + assetCoverage: Score100, + captionsPackaging: Score100, + outputWatchability: Score100, + renderReadiness: Score100, + explainability: Score100, + riskControl: Score100, +}); +export type QCScores = z.infer; + +export const QCIssue = z.object({ + id: z.string(), + severity: QCSeverity, + module: QCModule, + description: z.string(), + suggestion: z.string(), + targetAgent: z.string().optional(), + artifactRef: z.string().optional(), + timeRange: z.string().optional(), +}); +export type QCIssue = z.infer; + +export const QCReport = z.object({ + totalScore: Score100, + verdict: z.enum(['pass', 'conditional_pass', 'fail']), + scores: QCScores, + issues: z.array(QCIssue).default([]), + evidenceRefs: z.array(z.string()).default([]), + rationale: z.string().default(''), +}); +export type QCReport = z.infer; + +export const RevisionItem = z.object({ + issueId: z.string(), + targetAgent: z.string(), + targetArtifact: z.string(), + requiredChange: z.string(), + acceptanceCriteria: z.string(), +}); +export type RevisionItem = z.infer; + +export const RevisionPlan = z.object({ + items: z.array(RevisionItem).default([]), + rationale: z.string().default(''), +}); +export type RevisionPlan = z.infer; + +export const DirectorArtifacts = z.object({ + creativeBrief: CreativeBrief, + beatMap: BeatMap, + shotList: ShotList, + assetPlan: AssetPlan, + editDecisionList: EditDecisionList, + qcReport: QCReport, + revisionPlan: RevisionPlan, +}); +export type DirectorArtifacts = z.infer; + +export interface BuildDirectorArtifactsInput { + blueprint: VideoStructureBlueprint; + assets: TaggedAsset[]; + topic: string; + sellingPoints: string[]; + durationSec: number; + directorPlan?: DirectorPlan; + matches: SlotMatch[]; + gaps: Gap[]; + fills: FillArtifact[]; + script: ScriptLine[]; + storyboard: StoryboardItem[]; + timeline: Timeline; + evidence: Evidence[]; +} + +export interface BuildDirectorPlanInput { + blueprint: VideoStructureBlueprint; + assets: TaggedAsset[]; + referenceAssets?: TaggedAsset[]; + topic: string; + sellingPoints: string[]; + durationSec: number; + targetShotSec: number; + cutDensity: string; + selectedPattern?: LearnedSamplePattern; + migrationIntent?: z.infer; + referenceClipMode?: z.infer; + cardTreatment: { + style: z.infer; + animation: z.infer; + }; + evidence?: Evidence[]; +} + +interface CreativeBriefInput { + blueprint: VideoStructureBlueprint; + topic: string; + sellingPoints: string[]; + durationSec: number; + evidence: Evidence[]; +} + +export function buildDirectorPlan(input: BuildDirectorPlanInput): DirectorPlan { + const evidence = input.evidence ?? input.blueprint.evidence; + const creativeBrief = buildCreativeBrief({ + blueprint: input.blueprint, + topic: input.topic, + sellingPoints: input.sellingPoints, + durationSec: input.durationSec, + evidence, + }); + const assetBudget = assessAssetBudget(input.assets, input.durationSec); + const shots = buildDirectedShots(input, assetBudget); + const firstSegment = input.blueprint.scriptStructure.segments[0]; + const lastSegment = input.blueprint.scriptStructure.segments.at(-1); + const sampleStoryArc = buildSampleStoryArc(input.blueprint, input.topic); + const storyArc = storyArcForInput(input, firstSegment, lastSegment); + + return DirectorPlan.parse({ + id: `dp_${randomUUID().slice(0, 8)}`, + creativeBrief, + sampleStoryArc, + selectedPatternId: input.selectedPattern?.id, + storyArc, + assetBudget, + editConstraints: { + durationSec: input.durationSec, + targetShotSec: input.targetShotSec, + cutDensity: input.cutDensity, + subtitleDensity: input.blueprint.packagingStructure?.subtitleDensity, + packagingPreset: packagingPresetFor(input.cardTreatment.style), + cardStylePreset: input.cardTreatment.style, + cardAnimationPreset: input.cardTreatment.animation, + maxContinuousAssetSec: Math.min(5, Math.max(3.2, input.durationSec * 0.28)), + }, + fillPolicy: { + preferredOrder: fillPolicyOrder(assetBudget, Boolean(input.referenceAssets?.length)), + rationale: + assetBudget.repeatedVisualRisk === 'high' + ? '可用用户视觉素材有限,优先结构重排、文案补全、包装补全和 AIGC 补全;只有这些方式影响流畅性或结构完整性时,才低优先级复用现有素材或参考样例素材。' + : '可用用户视觉素材较足,优先使用用户素材;遇到缺口时仍先尝试结构重排、文案/包装/AIGC 补全,再考虑复用现有素材。', + }, + shots, + evidence, + rationale: + 'DirectorPlan 前置生成:先根据样例结构、新主题和素材预算设计可执行 shot,再交给 ExpertMigration 写具体文案和分镜表达。', + }); +} + +export function buildSampleStoryArc(blueprint: VideoStructureBlueprint, topic: string): SampleStoryArc { + const beats = blueprint.scriptStructure.segments.map((segment) => ({ + segmentRole: segment.role, + label: sampleStoryBeat(segment), + intent: segment.intent, + copyPattern: segment.copyPattern, + storyFunction: storyFunctionForSegment(segment), + })); + const byRole = new Map(beats.map((beat) => [beat.segmentRole, beat])); + return SampleStoryArc.parse({ + premise: `按样例的 ${blueprint.videoGenre} 叙事,把「${topic}」写成同类观看路径。`, + openingQuestion: storyArcPart(byRole.get('hook'), '开场问题'), + setupSituation: storyArcPart(byRole.get('setup'), '人物 / 场景处境'), + progressionPattern: storyArcPart(byRole.get('develop'), '行动推进'), + turnOrProof: storyArcPart(byRole.get('climax'), '反转 / 证明'), + payoff: storyArcPart(byRole.get('closing'), '情绪或行动收束'), + emotionalCurve: beats.map((beat) => beat.label), + beatFunctions: beats, + }); +} + +function storyArcForInput( + input: BuildDirectorPlanInput, + firstSegment: Segment | undefined, + lastSegment: Segment | undefined, +): DirectorPlan['storyArc'] { + if (hasExplicitCreativeDirection(input.sellingPoints)) { + return { + opening: `用「${firstSegment?.copyPattern ?? '抓停开场'}」把观众带入 ${input.topic}。`, + setup: `用样例的铺垫方式建立 ${input.topic} 的理解入口。`, + progression: `围绕 ${input.sellingPoints[0] ?? input.topic} 递进展示,素材不足处用包装卡片承接信息。`, + turn: `在重点段落用 ${input.sellingPoints[1] ?? input.sellingPoints[0] ?? input.topic} 制造信息推进或情绪转折。`, + payoff: `以「${lastSegment?.copyPattern ?? '记忆点收束'}」完成观众可记住的结束感。`, + emotionalCurve: ['抓停', '建立理解', '信息推进', '重点放大', '记忆收束'], + }; + } + + const segments = input.blueprint.scriptStructure.segments; + const setup = segments.find((segment) => segment.role === 'setup'); + const develop = segments.find((segment) => segment.role === 'develop'); + const climax = segments.find((segment) => segment.role === 'climax'); + return { + opening: sampleStoryArcLine(firstSegment, input.topic, '开场'), + setup: sampleStoryArcLine(setup, input.topic, '铺垫'), + progression: sampleStoryArcLine(develop, input.topic, '推进'), + turn: sampleStoryArcLine(climax, input.topic, '转折'), + payoff: sampleStoryArcLine(lastSegment, input.topic, '收束'), + emotionalCurve: segments.map((segment) => segment.label ?? roleName(segment.role)), + }; +} + +function hasExplicitCreativeDirection(sellingPoints: string[]): boolean { + return sellingPoints.map((point) => point.trim()).filter(Boolean).length > 0; +} + +function sampleStoryArcLine(segment: Segment | undefined, topic: string, fallback: string): string { + if (!segment) return `按样例的${fallback}功能,把 ${topic} 写成同类故事段落。`; + const label = segment.label ?? roleName(segment.role); + return `迁移样例「${label}」:${segment.intent};新片围绕 ${topic} 写类似的${fallback}段落,而不是重新发明通用卖点结构。`; +} + +function sampleStoryBeat(segment: Segment): string { + return segment.label || segment.copyPattern || roleName(segment.role); +} + +function storyArcPart(beat: SampleStoryBeat | undefined, fallback: string): string { + if (!beat) return fallback; + return `${beat.label}:${beat.intent}(${beat.copyPattern})`; +} + +function storyFunctionForSegment(segment: Segment): StoryFunction { + const text = `${segment.label ?? ''} ${segment.intent} ${segment.copyPattern}`.toLowerCase(); + if (segment.role === 'hook') return 'opening_hook'; + if (segment.role === 'setup') { + if (/人物|主角|身份|character|person/.test(text)) return 'character'; + return 'context'; + } + if (segment.role === 'develop') { + if (/细节|特写|质感|detail/.test(text)) return 'detail'; + if (/对比|反差|contrast/.test(text)) return 'contrast'; + return 'action'; + } + if (segment.role === 'climax') { + if (/反转|转折|turn/.test(text)) return 'turn'; + return 'proof'; + } + if (segment.role === 'closing') { + if (/cta|行动|购买|下单|收藏|关注/.test(text)) return 'cta'; + return 'payoff'; + } + return 'mood'; +} + +function sampleStoryPurpose(segment: Segment, topic: string, index: number, total: number): string { + const slice = total > 1 ? `(第 ${index + 1}/${total} 镜)` : ''; + return `按样例「${sampleStoryBeat(segment)}」的段落功能迁移到 ${topic}${slice}:${segment.intent}`; +} + +function buildDirectedShots(input: BuildDirectorPlanInput, assetBudget: AssetBudget): DirectorPlanShot[] { + const shots: DirectorPlanShot[] = []; + let cursor = 0; + let shotIndex = 0; + const creativeBrief = buildCreativeBrief({ + blueprint: input.blueprint, + topic: input.topic, + sellingPoints: input.sellingPoints, + durationSec: input.durationSec, + evidence: input.evidence ?? input.blueprint.evidence, + }); + const selectedHookText = + creativeBrief.hookCandidates.find((hook) => hook.id === creativeBrief.selectedHookId)?.text ?? + creativeBrief.hookCandidates[0]?.text; + + input.blueprint.scriptStructure.segments.forEach((segment, segmentIndex) => { + const isLast = segmentIndex === input.blueprint.scriptStructure.segments.length - 1; + const startSec = round(cursor); + const endSec = isLast ? input.durationSec : round(cursor + segment.durationRatio * input.durationSec); + cursor = endSec; + const slot = input.blueprint.slots.find((s) => s.segmentRole === segment.role); + const assetNeed: TaggedAsset['assetTags'] = slot?.requiredAssetTypes.length ? [...slot.requiredAssetTypes] : ['b_roll']; + const preferredAssetIds = preferredAssetsFor(assetNeed, input.assets); + const sliceCount = directedShotCount({ + startSec, + endSec, + targetShotSec: input.targetShotSec, + cutDensity: input.cutDensity, + assetNeed, + assetBudget, + }); + + for (let i = 0; i < sliceCount; i++) { + shotIndex += 1; + const shotStart = round(startSec + ((endSec - startSec) * i) / sliceCount); + const shotEnd = i === sliceCount - 1 ? endSec : round(startSec + ((endSec - startSec) * (i + 1)) / sliceCount); + const fallbackStrategies = fallbackStrategiesForNeed( + assetNeed, + preferredAssetIds.length > 0, + assetBudget, + Boolean(input.referenceAssets?.length) && input.referenceClipMode === 'allow_reference_clip', + ); + const copyPlan = directorCopyPlanFor({ + topic: input.topic, + sellingPoints: input.sellingPoints, + role: segment.role, + indexInSegment: i, + sliceCount, + assetNeed, + fallbackStrategies, + hasPreferredAsset: preferredAssetIds.length > 0, + copyPattern: segment.copyPattern, + selectedHookText, + captionStyle: segment.captionStyle, + subtitleDensity: input.blueprint.packagingStructure?.subtitleDensity, + }); + const visualRole = directorVisualRole(segment, assetNeed, i, sliceCount); + const shotScale = directorShotScale(segment, visualRole, assetNeed, i); + const visualFunctions = visualFunctionsForShot({ + segment, + visualRole, + shotScale, + storyFunction: storyFunctionForSegment(segment), + indexInSegment: i, + sliceCount, + }); + + shots.push({ + shotId: `shot_${shotIndex}`, + segmentRole: segment.role, + slotId: slot?.id, + startSec: shotStart, + endSec: shotEnd, + purpose: hasExplicitCreativeDirection(input.sellingPoints) + ? purposeForRole(segment.role, i, sliceCount) + : sampleStoryPurpose(segment, input.topic, i, sliceCount), + storyBeat: sampleStoryBeat(segment), + storyFunction: storyFunctionForSegment(segment), + visualDirection: directorVisualDirection({ + topic: input.topic, + segmentRole: segment.role, + visualRole, + shotScale, + assetNeed, + preferredAssetIds, + assets: input.assets, + fallbackStrategies, + hasReferenceAsset: Boolean(input.referenceAssets?.length) && input.referenceClipMode === 'allow_reference_clip', + repeatedVisualRisk: assetBudget.repeatedVisualRisk, + }), + visualRole, + shotScale, + visualFunctions, + communicationIntent: copyPlan.communicationIntent, + copyMode: copyPlan.copyMode, + copyRequired: copyPlan.copyRequired, + copyPurpose: copyPlan.copyPurpose, + screenTextIntent: copyPlan.screenTextIntent, + assetNeed: [...assetNeed], + preferredAssetIds, + fallbackStrategies, + motionPreset: directorMotionPreset({ + density: input.cutDensity, + role: segment.role, + visualRole, + shotScale, + index: shotIndex, + indexInSegment: i, + hasPreferredAsset: preferredAssetIds.length > 0, + segmentMotionIntent: segment.motionIntent, + }), + cropPreset: directorCropPreset(segment.role, shotIndex, shotScale), + transitionPreset: directorTransitionPreset({ + density: input.cutDensity, + index: shotIndex, + firstFallback: fallbackStrategies[0], + visualRole, + segmentTransitionIntent: segment.transitionIntent, + }), + mustShow: directorMustShow(input.topic, input.sellingPoints, segment.role, assetNeed), + }); + } + }); + + return shots; +} + +function beatMapFromDirectorPlan(plan: DirectorPlan): BeatMap { + return BeatMap.parse({ + durationSec: plan.editConstraints.durationSec, + beats: plan.shots.map((shot) => ({ + id: `beat_${shot.shotId}`, + segmentRole: shot.segmentRole, + startSec: shot.startSec, + endSec: shot.endSec, + purpose: shot.purpose, + visualChange: shot.visualDirection, + screenText: shot.copyRequired ? shortText(shot.communicationIntent || shot.screenTextIntent, 28) : '', + audioCue: audioCueForRole(shot.segmentRole, plan.editConstraints.cutDensity), + })), + rationale: 'VideoDirectorAgent 前置版本:beat 直接来自 DirectorPlan 的 directedShots,后续 timeline 必须按 shotRef 执行。', + }); +} + +function shotListFromDirectorPlan(plan: DirectorPlan): ShotList { + return ShotList.parse({ + shots: plan.shots.map((shot) => ({ + shotId: shot.shotId, + beatId: `beat_${shot.shotId}`, + segmentRole: shot.segmentRole, + purpose: shot.purpose, + subject: shot.preferredAssetIds.length + ? `候选素材 ${shot.preferredAssetIds.join('/')}` + : shot.mustShow, + action: `${shot.visualDirection};叙事职责 ${shot.storyFunction};视觉功能 ${shot.visualFunctions.join('/')}`, + composition: compositionForRole(shot.segmentRole), + motion: shot.motionPreset, + assetNeed: shot.assetNeed, + sourcePreference: shot.preferredAssetIds.length ? 'user_asset' : shot.fallbackStrategies[0], + })), + rationale: 'VideoDirectorAgent 前置版本:shotList 是 DirectorPlan 的执行视图,不再由成片 timeline 事后反推。', + }); +} + +export function buildDirectorArtifacts(input: BuildDirectorArtifactsInput): DirectorArtifacts { + const creativeBrief = input.directorPlan?.creativeBrief ?? buildCreativeBrief(input); + const beatMap = input.directorPlan ? beatMapFromDirectorPlan(input.directorPlan) : buildBeatMap(input); + const shotList = input.directorPlan ? shotListFromDirectorPlan(input.directorPlan) : buildShotList(input, beatMap); + const assetPlan = buildAssetPlan(input, shotList); + const editDecisionList = buildEditDecisionList(input, beatMap, shotList); + const qcReport = buildQcReport(input, creativeBrief, beatMap, shotList, assetPlan, editDecisionList); + const revisionPlan = buildRevisionPlan(qcReport); + + return DirectorArtifacts.parse({ + creativeBrief, + beatMap, + shotList, + assetPlan, + editDecisionList, + qcReport, + revisionPlan, + }); +} + +function buildCreativeBrief(input: CreativeBriefInput): CreativeBrief { + const firstPoint = input.sellingPoints[0] ?? '核心看点'; + const secondPoint = input.sellingPoints[1] ?? firstPoint; + const genreGoal = goalForGenre(input.blueprint.videoGenre); + const hookPattern = + input.blueprint.scriptStructure.segments.find((s) => s.role === 'hook')?.copyPattern || + '反差 / 悬念开场'; + const hookCandidates: HookCandidate[] = [ + { + id: 'hook_1', + type: '反差抓停型', + text: compactHook(`别急着买,先看${firstPoint}`), + score: hookScore(9, 9, 9, 8), + rationale: `承接样例的 ${hookPattern},用反差和行动阻断制造首屏停留。`, + }, + { + id: 'hook_2', + type: '结果前置型', + text: compactHook(`${Math.round(input.durationSec)}秒看懂${secondPoint}`), + score: hookScore(9, 8, 8, 8), + rationale: '先给清晰结果承诺,再让中段负责兑现。', + }, + { + id: 'hook_3', + type: '好奇缺口型', + text: compactHook('香不香,看这一镜'), + score: hookScore(8, 9, 8, 9), + rationale: '用具体观看动作建立好奇缺口,适合产品质感和生活方式视频。', + }, + ]; + + return CreativeBrief.parse({ + topic: input.topic, + audience: `对「${input.topic}」感兴趣、需要快速理解重点的短视频观众`, + platform: 'vertical_short_video', + goal: genreGoal, + durationSec: input.durationSec, + corePromise: `用样例的观看结构讲清「${input.topic}」:先抓住注意力,再逐步建立理解和记忆点。`, + tone: toneForGenre(input.blueprint.videoGenre), + hookCandidates, + selectedHookId: hookCandidates[0].id, + hookRationale: hookCandidates[0].rationale, + evidence: input.evidence, + rationale: 'CreativeDirectorAgent 启发式版本:优先保留样例 hook 模式,同时让新主题只有一个核心观看承诺。', + }); +} + +function buildBeatMap(input: BuildDirectorArtifactsInput): BeatMap { + const beats: Beat[] = []; + let beatIndex = 1; + + for (const line of input.script) { + const duration = line.endSec - line.startSec; + const parts = Math.max(1, Math.ceil(duration / 3)); + for (let i = 0; i < parts; i++) { + const startSec = round(line.startSec + (duration * i) / parts); + const endSec = i === parts - 1 ? line.endSec : round(line.startSec + (duration * (i + 1)) / parts); + const storyboard = findStoryboard(input.storyboard, line.segmentRole, startSec, endSec); + beats.push({ + id: `beat_${beatIndex++}`, + segmentRole: line.segmentRole, + startSec, + endSec, + purpose: purposeForRole(line.segmentRole, i, parts), + visualChange: storyboard?.visual ?? `${roleName(line.segmentRole)}:保持信息推进`, + screenText: shortText(i === 0 ? visibleCopy(line) : continueText(line.segmentRole, input.topic), 28), + audioCue: audioCueForRole(line.segmentRole, input.blueprint.rhythmStructure.cutDensity), + }); + } + } + + return BeatMap.parse({ + durationSec: input.durationSec, + beats, + rationale: 'VideoDirectorAgent 启发式版本:将段落按不超过约 3 秒切为 beat,保证持续有信息或视觉变化。', + }); +} + +function buildShotList(input: BuildDirectorArtifactsInput, beatMap: BeatMap): ShotList { + const shots = beatMap.beats.map((beat, index) => { + const slot = input.blueprint.slots.find((s) => s.segmentRole === beat.segmentRole); + const match = slot ? input.matches.find((m) => m.slotId === slot.id) : undefined; + const fill = slot ? input.fills.find((f) => f.slotId === slot.id) : undefined; + const assetNeed = slot?.requiredAssetTypes.length ? slot.requiredAssetTypes : (['text_card'] as const); + + return { + shotId: `shot_${index + 1}`, + beatId: beat.id, + segmentRole: beat.segmentRole, + purpose: beat.purpose, + subject: subjectForBeat(input.topic, beat.segmentRole, match?.assetId), + action: actionForBeat(beat.segmentRole, beat.visualChange), + composition: compositionForRole(beat.segmentRole), + motion: motionForDensity(input.blueprint.rhythmStructure.cutDensity, index), + assetNeed: [...assetNeed], + sourcePreference: sourcePreference(match, fill), + }; + }); + + return ShotList.parse({ + shots, + rationale: 'VideoDirectorAgent 启发式版本:每个 beat 至少对应一个 shot,并显式声明素材需求与运动方式。', + }); +} + +function buildAssetPlan(input: BuildDirectorArtifactsInput, shotList: ShotList): AssetPlan { + const items = shotList.shots.map((shot) => { + const slot = input.blueprint.slots.find((s) => s.segmentRole === shot.segmentRole); + const match = slot ? input.matches.find((m) => m.slotId === slot.id) : undefined; + const gap = slot ? input.gaps.find((g) => g.slotId === slot.id) : undefined; + const fill = slot ? input.fills.find((f) => f.slotId === slot.id) : undefined; + + return { + shotId: shot.shotId, + slotId: slot?.id, + requiredTags: shot.assetNeed, + matchedAssetId: match?.status === 'matched' ? match.assetId : undefined, + gapId: gap?.slotId, + fillArtifactId: fill?.id, + fillStrategy: fill?.kind ?? gap?.recommendedStrategies[0], + reason: + match?.status === 'matched' + ? `使用匹配素材 ${match.assetId}:${match.reason}` + : gap + ? `${gap.impactOnSegment},使用 ${fill?.kind ?? gap.recommendedStrategies[0]} 兜底。` + : '无明确槽位,使用文字或包装承接该 beat。', + }; + }); + + return AssetPlan.parse({ + items, + rationale: 'AssetProducerAgent 启发式版本:把 shot-level 需求映射到素材、gap 和可上时间线的补全产物。', + }); +} + +function buildEditDecisionList( + input: BuildDirectorArtifactsInput, + beatMap: BeatMap, + shotList: ShotList, +): EditDecisionList { + const decisions = input.timeline.items.map((item, index) => { + const beat = findBeat(beatMap.beats, item); + const shot = beat ? shotList.shots.find((s) => s.beatId === beat.id) : undefined; + const line = findScriptLine(input.script, item.startSec, item.endSec); + return { + id: `edit_${index + 1}`, + beatId: beat?.id, + shotId: shot?.shotId, + startSec: item.startSec, + endSec: item.endSec, + sourceKind: item.source.kind, + sourceRef: sourceRef(item), + subtitle: line?.screenText || beat?.screenText || '', + overlay: overlayForItem(input, item), + transition: item.transitionPreset ?? transitionForIndex(input.blueprint.rhythmStructure.cutDensity, index), + reason: beat + ? `服务 ${beat.id}:${beat.purpose};运镜 ${item.motionPreset ?? 'static'}` + : '服务当前段落的信息承接,保持时间线连续。', + }; + }); + + return EditDecisionList.parse({ + decisions, + rationale: 'EditorAgent 启发式版本:每个 timeline item 都关联 beat / shot,并记录字幕、转场和来源理由。', + }); +} + +function buildQcReport( + input: BuildDirectorArtifactsInput, + creativeBrief: CreativeBrief, + beatMap: BeatMap, + shotList: ShotList, + assetPlan: AssetPlan, + editDecisionList: EditDecisionList, +): QCReport { + const issues: QCIssue[] = []; + const maxBeatSec = Math.max(...beatMap.beats.map((b) => b.endSec - b.startSec)); + const matchedSlots = input.matches.filter((m) => m.status === 'matched').length; + const matchRatio = input.matches.length ? matchedSlots / input.matches.length : 1; + const filledGapRatio = input.gaps.length + ? input.gaps.filter((g) => input.fills.some((f) => f.slotId === g.slotId)).length / input.gaps.length + : 1; + const generatedCardSec = sumGeneratedCardDuration(input.timeline, input.fills); + const textCardRatio = input.durationSec > 0 ? generatedCardSec / input.durationSec : 0; + const visualItems = input.timeline.items.filter((item) => item.track !== 'audio'); + const orderedVisualItems = [...visualItems].sort((a, b) => a.startSec - b.startSec); + const firstVisual = orderedVisualItems[0]; + const firstVisualIsGeneratedCard = firstVisual ? isTimelineGeneratedCard(firstVisual, input.fills) : true; + const motionItemRatio = visualItems.length + ? visualItems.filter((item) => item.motionPreset && item.motionPreset !== 'static').length / visualItems.length + : 0; + const longestRepeatedSourceSec = longestRepeatedSourceRun(input.timeline); + const plannedShotIds = new Set(input.directorPlan?.shots.map((shot) => shot.shotId) ?? []); + const executedShotIds = new Set(visualItems.map((item) => item.shotRef).filter(Boolean)); + const missingDirectorShots = [...plannedShotIds].filter((shotId) => !executedShotIds.has(shotId)); + const storyCoverage = storyFunctionCoverage(input); + + if (input.gaps.length > 0) { + issues.push({ + id: 'qc_asset_gaps', + severity: input.gaps.length >= Math.max(2, input.matches.length) ? 'high' : 'medium', + module: 'asset', + description: `仍有 ${input.gaps.length} 个素材缺口依赖补全。`, + suggestion: '优先补充真实视频 / 图片素材,或把 text_card 升级为包装卡片 / 动态素材。', + targetAgent: 'AssetProducerAgent', + artifactRef: 'assetPlan', + }); + } + if (maxBeatSec > 3.5) { + issues.push({ + id: 'qc_long_beat', + severity: 'medium', + module: 'pacing', + description: `最长 beat 为 ${maxBeatSec.toFixed(1)}s,节奏变化偏慢。`, + suggestion: '把长 beat 拆成更短的信息点,并增加画面或字幕变化。', + targetAgent: 'VideoDirectorAgent', + artifactRef: 'beatMap', + }); + } + if (textCardRatio > MAX_FULL_SCREEN_TEXT_CARD_RATIO) { + issues.push({ + id: 'qc_textcard_ratio', + severity: textCardRatio > 0.28 ? 'high' : 'medium', + module: 'shot', + description: `文字卡占比约 ${(textCardRatio * 100).toFixed(0)}%,成片可能像 PPT。`, + suggestion: '把 closing/payoff 和超预算文字卡改为真实素材背景 + 下三分之一文案,全屏文字卡控制在 10%-12% 内。', + targetAgent: 'EditorAgent', + artifactRef: 'timeline', + }); + } + if (firstVisualIsGeneratedCard) { + issues.push({ + id: 'qc_first_screen_asset', + severity: 'medium', + module: 'watchability', + description: '首屏使用文字/包装卡承接,缺少真实画面抓停。', + suggestion: '优先给 hook 分配 opening / conflict / texture 类真实素材,无法满足时缩短文字卡到 1.2 秒内。', + targetAgent: 'CreativeDirectorAgent', + artifactRef: 'timeline', + timeRange: '0-2s', + }); + } + if (motionItemRatio < 0.5) { + issues.push({ + id: 'qc_motion_not_executable', + severity: 'medium', + module: 'render', + description: `只有约 ${(motionItemRatio * 100).toFixed(0)}% 的视觉片段带可执行运镜 preset。`, + suggestion: '为真实素材和复用素材写入 motionPreset,并在 RenderAgent 中执行 Ken Burns / push-in / pan。', + targetAgent: 'EditorAgent', + artifactRef: 'timeline', + }); + } + if (longestRepeatedSourceSec > 5.5) { + issues.push({ + id: 'qc_repeated_source_run', + severity: 'medium', + module: 'asset', + description: `同一素材连续使用最长约 ${longestRepeatedSourceSec.toFixed(1)}s,观感可能像素材拼接。`, + suggestion: '触发素材重复护栏,优先切换其他素材;缺素材时插入包装卡片 / AIGC 补充画面。', + targetAgent: 'EditorAgent', + artifactRef: 'timeline', + }); + } + if (missingDirectorShots.length > 0) { + issues.push({ + id: 'qc_director_plan_not_executed', + severity: 'high', + module: 'shot', + description: `${missingDirectorShots.length} 个 DirectorPlan shot 没有对应 timeline item。`, + suggestion: 'EditorAgent 必须按 DirectorPlan.shots 逐条生成 timeline item,并保留 shotRef。', + targetAgent: 'EditorAgent', + artifactRef: 'timeline', + }); + } + if (storyCoverage < 0.75) { + issues.push({ + id: 'qc_story_closure', + severity: storyCoverage < 0.5 ? 'high' : 'medium', + module: 'structure', + description: `只有约 ${(storyCoverage * 100).toFixed(0)}% 的关键故事职责被匹配素材或补全兑现,成片可能没有完整故事闭环。`, + suggestion: '按 opening/context/action/proof/payoff 检查素材覆盖;缺少证明或收束镜头时生成显式 gap,不要用普通 b-roll 硬顶。', + targetAgent: 'VideoDirectorAgent', + artifactRef: 'DirectorPlan.shots', + }); + } + if (!creativeBrief.hookCandidates.length || creativeBrief.hookCandidates[0].score.total < 70) { + issues.push({ + id: 'qc_hook_weak', + severity: 'medium', + module: 'hook', + description: 'Hook 候选评分偏低。', + suggestion: '让 CreativeDirectorAgent 重新生成更具体的冲突、反差或结果前置 Hook。', + targetAgent: 'CreativeDirectorAgent', + artifactRef: 'creativeBrief', + }); + } + + const scores: QCScores = { + hookStrength: creativeBrief.hookCandidates.find((h) => h.id === creativeBrief.selectedHookId)?.score.total ?? 70, + structureClarity: input.script.length >= 3 ? 86 : 68, + storyClosure: clampScore(45 + storyCoverage * 50), + pacingChange: clampScore(maxBeatSec <= 3.1 ? 88 : 88 - (maxBeatSec - 3.1) * 10), + shotExecutability: clampScore(70 + executableShotRatio(shotList, assetPlan) * 25), + assetCoverage: clampScore(55 + matchRatio * 30 + filledGapRatio * 15), + captionsPackaging: input.blueprint.packagingStructure ? 82 : 72, + outputWatchability: clampScore( + 88 - + textCardRatio * 34 - + Math.max(0, longestRepeatedSourceSec - 3.5) * 5 - + (firstVisualIsGeneratedCard ? 12 : 0) + + motionItemRatio * 6, + ), + renderReadiness: + input.timeline.items.length > 0 && editDecisionList.decisions.length > 0 + ? clampScore(78 + motionItemRatio * 12) + : 45, + explainability: clampScore(65 + input.evidence.length * 3 + input.matches.length * 2), + riskControl: input.gaps.length ? 76 : 86, + }; + const totalScore = weightedTotal(scores); + const hasHigh = issues.some((i) => i.severity === 'high'); + const verdict = totalScore >= 80 && !hasHigh ? 'pass' : totalScore >= 65 && !hasHigh ? 'conditional_pass' : 'fail'; + + return QCReport.parse({ + totalScore, + verdict, + scores, + issues, + evidenceRefs: input.evidence.map((e) => e.ref ?? e.type).filter(Boolean), + rationale: 'QCReviewerAgent 启发式版本:先用结构完整度、节奏、素材覆盖、文字卡占比和可解释性做自动验收。', + }); +} + +function buildRevisionPlan(qcReport: QCReport): RevisionPlan { + const items = qcReport.issues + .filter((issue) => issue.severity !== 'low') + .map((issue) => ({ + issueId: issue.id, + targetAgent: issue.targetAgent ?? targetAgentForModule(issue.module), + targetArtifact: issue.artifactRef ?? artifactForModule(issue.module), + requiredChange: issue.suggestion, + acceptanceCriteria: acceptanceForIssue(issue), + })); + + return RevisionPlan.parse({ + items, + rationale: items.length + ? 'RevisionPlannerAgent 启发式版本:把 QC 问题路由到最小返工目标。' + : 'QC 未发现需要返工的问题。', + }); +} + +function assessAssetBudget(assets: TaggedAsset[], durationSec: number): AssetBudget { + const reusable = assets.filter((asset) => asset.mediaType === 'video' || asset.mediaType === 'image'); + const usableVisualSec = reusable.reduce((sum, asset) => { + if (asset.mediaType === 'image') return sum + 3.2; + return sum + (asset.durationSec ?? 4); + }, 0); + const repeatedVisualRisk = + reusable.length <= 1 || usableVisualSec < durationSec * 0.55 + ? 'high' + : reusable.length <= 2 || usableVisualSec < durationSec * 0.9 + ? 'medium' + : 'low'; + const notes = [ + `可用视觉素材 ${reusable.length} 个,估算可承载 ${usableVisualSec.toFixed(1)}s。`, + repeatedVisualRisk === 'high' + ? '视觉素材预算偏紧,导演计划需要主动插入包装卡片或局部裁切变化。' + : repeatedVisualRisk === 'medium' + ? '视觉素材数量有限,真实素材之间需要更明确的运镜和转场差异。' + : '视觉素材预算较充足,可优先用真实素材完成 shot。' + ]; + + return AssetBudget.parse({ + reusableAssetCount: reusable.length, + usableVisualSec: round(usableVisualSec), + repeatedVisualRisk, + notes, + }); +} + +function directedShotCount(opts: { + startSec: number; + endSec: number; + targetShotSec: number; + cutDensity: string; + assetNeed: TaggedAsset['assetTags']; + assetBudget: AssetBudget; +}): number { + const duration = Math.max(0.001, opts.endSec - opts.startSec); + if (isStandaloneTextNeed(opts.assetNeed)) { + return 1; + } + if (opts.assetBudget.repeatedVisualRisk === 'high') { + return duration > opts.targetShotSec * 1.8 ? 2 : 1; + } + const maxPerSegment = opts.cutDensity === 'high' ? 5 : opts.cutDensity === 'medium' ? 4 : 3; + return Math.max(1, Math.min(maxPerSegment, Math.round(duration / opts.targetShotSec))); +} + +function preferredAssetsFor(assetNeed: TaggedAsset['assetTags'], assets: TaggedAsset[]): string[] { + return assets + .map((asset) => { + const effectiveTags = effectiveAssetTags(asset); + const missingStrict = strictNeededTags(assetNeed).filter((tag) => !asset.assetTags.includes(tag)); + const overlap = effectiveTags.filter((tag) => assetNeed.includes(tag)).length; + const score = missingStrict.length === 0 ? overlap * asset.confidence : 0; + return { asset, score }; + }) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map((item) => item.asset.id); +} + +function effectiveAssetTags(asset: TaggedAsset): TaggedAsset['assetTags'] { + if (asset.mediaType !== 'video' && asset.mediaType !== 'image') return asset.assetTags; + return asset.assetTags.includes('b_roll') ? asset.assetTags : [...asset.assetTags, 'b_roll']; +} + +function strictNeededTags(assetNeed: TaggedAsset['assetTags']): TaggedAsset['assetTags'] { + return assetNeed.filter((tag) => tag === 'talking_head' || (tag === 'text_card' && isStandaloneTextNeed(assetNeed))); +} + +function isStandaloneTextNeed(assetNeed: TaggedAsset['assetTags']): boolean { + return assetNeed.includes('text_card') && !assetNeed.some((tag) => tag !== 'text_card'); +} + +function fallbackStrategiesForNeed( + assetNeed: TaggedAsset['assetTags'], + hasPreferredAsset: boolean, + assetBudget: AssetBudget, + hasReferenceAsset: boolean, +): SourcePreference[] { + const completionFirst: SourcePreference[] = [ + 'structure_reframe', + 'copy_completion', + 'packaging_overlay', + 'aigc', + ]; + const existingFallbacks: SourcePreference[] = [ + ...(hasReferenceAsset ? (['reference_clip'] as SourcePreference[]) : []), + 'reused_clip', + ]; + if (hasPreferredAsset && assetBudget.repeatedVisualRisk !== 'high') { + return ['user_asset', ...completionFirst, ...existingFallbacks]; + } + if (assetNeed.includes('text_card') || assetNeed.includes('talking_head')) { + return ['copy_completion', 'packaging_overlay', 'aigc', ...existingFallbacks]; + } + if (hasPreferredAsset) return ['user_asset', ...completionFirst, ...existingFallbacks]; + return [...completionFirst, ...existingFallbacks]; +} + +function directorVisualRole( + segment: Segment, + assetNeed: TaggedAsset['assetTags'], + indexInSegment: number, + sliceCount: number, +): z.infer { + if (segment.visualRole) return segment.visualRole; + if (isStandaloneTextNeed(assetNeed)) return segment.role === 'closing' ? 'cta_card' : 'transition_card'; + if (assetNeed.includes('product_closeup')) return 'detail'; + if (assetNeed.includes('usage_demo')) return 'action'; + if (assetNeed.includes('comparison')) return 'proof'; + if (assetNeed.includes('talking_head')) return 'person_in_scene'; + if (segment.role === 'hook' && indexInSegment === 0) return 'establishing'; + if ( + segment.role === 'develop' || + /细节|特写|方案|卖点|质感|detail|product/i.test(`${segment.label ?? ''} ${segment.intent} ${segment.copyPattern}`) + ) { + return 'detail'; + } + if (segment.role === 'closing' && indexInSegment >= sliceCount - 1) return 'cta_card'; + if (segment.role === 'climax') return 'proof'; + return 'b_roll'; +} + +function directorShotScale( + segment: Segment, + visualRole: z.infer, + assetNeed: TaggedAsset['assetTags'], + indexInSegment: number, +): z.infer { + if (segment.shotScale) return segment.shotScale; + if (visualRole === 'establishing') return 'wide'; + if (visualRole === 'detail' || visualRole === 'proof' || assetNeed.includes('product_closeup')) return 'close'; + if (visualRole === 'person_in_scene' || visualRole === 'action') return 'medium'; + if (segment.role === 'hook' && indexInSegment === 0) return 'wide'; + if (segment.role === 'closing') return 'medium'; + return indexInSegment % 3 === 0 ? 'wide' : 'medium'; +} + +function visualFunctionsForShot(opts: { + segment: Segment; + visualRole: z.infer; + shotScale: z.infer; + storyFunction: StoryFunction; + indexInSegment: number; + sliceCount: number; +}): z.infer[] { + const functions: z.infer[] = []; + if (opts.visualRole === 'establishing') functions.push('establish_context'); + if (opts.visualRole === 'person_in_scene') functions.push('introduce_subject', 'show_emotion'); + if (opts.visualRole === 'action') functions.push('show_action', 'show_progression'); + if (opts.visualRole === 'detail') functions.push('show_detail'); + if (opts.visualRole === 'proof') functions.push('show_result'); + if (opts.visualRole === 'transition_card') functions.push('bridge_transition'); + if (opts.visualRole === 'cta_card') functions.push('call_to_action'); + + if (opts.shotScale === 'wide') functions.push('show_scale'); + if (opts.shotScale === 'close' || opts.shotScale === 'macro') functions.push('show_detail'); + if (opts.storyFunction === 'opening_hook' || opts.storyFunction === 'context') functions.push('establish_context'); + if (opts.storyFunction === 'character') functions.push('introduce_subject'); + if (opts.storyFunction === 'action' || opts.storyFunction === 'turn') functions.push('show_action'); + if (opts.storyFunction === 'detail' || opts.storyFunction === 'contrast') functions.push('show_detail'); + if (opts.storyFunction === 'proof' || opts.storyFunction === 'payoff') functions.push('show_result'); + if (opts.storyFunction === 'mood') functions.push('show_emotion'); + if (opts.storyFunction === 'transition') functions.push('bridge_transition'); + if (opts.storyFunction === 'cta') functions.push('call_to_action'); + if (opts.indexInSegment > 0 && opts.indexInSegment < opts.sliceCount - 1) functions.push('show_progression'); + return Array.from(new Set(functions.length ? functions : ['show_action'])); +} + +function directorVisualDirection(opts: { + topic: string; + segmentRole: z.infer; + visualRole: z.infer; + shotScale: z.infer; + assetNeed: TaggedAsset['assetTags']; + preferredAssetIds: string[]; + assets: TaggedAsset[]; + fallbackStrategies: SourcePreference[]; + hasReferenceAsset: boolean; + repeatedVisualRisk: AssetBudget['repeatedVisualRisk']; +}): string { + const assetSummary = opts.preferredAssetIds + .map((id) => opts.assets.find((asset) => asset.id === id)?.summary ?? id) + .join(' / '); + const roleHint = `${visualRoleName(opts.visualRole)} · ${shotScaleName(opts.shotScale)}`; + if (assetSummary) { + const riskNote = opts.repeatedVisualRisk === 'high' ? ',但必须通过裁切、推近或包装转场打断重复观感' : ''; + return `${roleName(opts.segmentRole)}:${roleHint},使用 ${assetSummary} 支撑画面${riskNote}`; + } + if (opts.fallbackStrategies[0] === 'text_card') { + return `${roleName(opts.segmentRole)}:${roleHint},缺少 ${opts.assetNeed.join('/')},用信息卡明确承接 ${opts.topic} 的关键点`; + } + if (opts.fallbackStrategies.includes('reference_clip') && opts.hasReferenceAsset) { + return `${roleName(opts.segmentRole)}:${roleHint},优先用结构重排、文案/包装或 AIGC 补全;若影响流畅性,再低优先级借用样例/优质视频参考素材。`; + } + return `${roleName(opts.segmentRole)}:${roleHint},优先复用现有动态素材,缺口处插入包装卡片说明 ${opts.topic}`; +} + +function fillPolicyOrder(assetBudget: AssetBudget, hasReferenceAsset: boolean): SourcePreference[] { + const completionFirst: SourcePreference[] = [ + 'structure_reframe', + 'copy_completion', + 'packaging_overlay', + 'aigc', + ...(hasReferenceAsset ? (['reference_clip'] as SourcePreference[]) : []), + 'reused_clip', + ]; + if (assetBudget.repeatedVisualRisk === 'high') return [...completionFirst, 'user_asset']; + return ['user_asset', ...completionFirst]; +} + +function directorScreenTextIntent( + topic: string, + sellingPoints: string[], + role: z.infer, + copyPattern: string, + selectedHookText?: string, +): string { + const point = sellingPoints[role === 'climax' ? 1 : 0] ?? sellingPoints[0] ?? topic; + if (role === 'hook') return selectedHookText ?? `${topic} 的反差 / 悬念必须在首屏可读`; + if (role === 'setup') return `一句话交代为什么 ${point} 值得继续看`; + if (role === 'develop') return `把 ${point} 变成可理解的核心信息`; + if (role === 'climax') return `用最强证据强化 ${point}`; + return `收束 ${topic} 的记忆点或行动`; +} + +/** + * 该段是否应使用上屏文字 / 字幕:从样例的字幕用法继承,而不是按角色硬加。 + * 优先看本段 `captionStyle.placement`(样例该段是否有上屏文字),缺省时回退到整体 `subtitleDensity`。 + * 样例无字幕(placement=none 或 density=sparse 且无该段信息)时,默认让镜头纯靠画面表达。 + */ +function segmentUsesOnScreenText( + captionStyle: Segment['captionStyle'], + subtitleDensity?: string, +): boolean { + const placement = captionStyle?.placement; + if (placement !== undefined) return placement !== 'none'; + if (subtitleDensity === undefined) return true; // 旧蓝图无任何字幕信息:保持既有行为,避免回归 + return subtitleDensity !== 'sparse'; +} + +function directorCopyPlanFor(opts: { + topic: string; + sellingPoints: string[]; + role: z.infer; + indexInSegment: number; + sliceCount: number; + assetNeed: TaggedAsset['assetTags']; + fallbackStrategies: SourcePreference[]; + hasPreferredAsset: boolean; + copyPattern: string; + selectedHookText?: string; + /** 样例该段的字幕用法(继承样例「用不用字幕」)。 */ + captionStyle?: Segment['captionStyle']; + /** 样例整体字幕密度,captionStyle 缺省时的回退依据。 */ + subtitleDensity?: string; +}): { + communicationIntent: string; + copyMode: ShotCopyMode; + copyRequired: boolean; + copyPurpose?: string; + screenTextIntent: string; +} { + const screenTextIntent = directorScreenTextIntent( + opts.topic, + opts.sellingPoints, + opts.role, + opts.copyPattern, + opts.selectedHookText, + ); + const point = opts.sellingPoints[opts.role === 'climax' ? 1 : 0] ?? opts.sellingPoints[0] ?? opts.topic; + const allowsText = segmentUsesOnScreenText(opts.captionStyle, opts.subtitleDensity); + // 纯结构性文字需求(槽位本身就是 text_card)即使样例少字幕也要承接,属于结构内容而非装饰性字幕。 + const standaloneTextNeed = isStandaloneTextNeed(opts.assetNeed); + const needsGeneratedCard = + standaloneTextNeed || + (opts.assetNeed.includes('text_card') && !opts.hasPreferredAsset) || + opts.fallbackStrategies[0] === 'text_card' || + opts.fallbackStrategies[0] === 'packaging_overlay'; + + // 角色化的上屏文字(卡片 / 钩子 / 收尾 / 轻字幕)只在样例该段确实使用字幕时才添加, + // 避免给无字幕样例硬加字幕;口播 voiceover 是音轨叙述而非上屏字幕,不受此约束。 + // 分支顺序保持与样例字幕用法解耦前一致,仅逐条加 allowsText 门控。 + if (needsGeneratedCard && (allowsText || standaloneTextNeed)) { + return { + communicationIntent: `${roleName(opts.role)} 需要用包装卡片承接信息:${screenTextIntent}`, + copyMode: 'title_card', + copyRequired: true, + copyPurpose: `把 ${point} 变成可读、可上屏的一句话。`, + screenTextIntent, + }; + } + + if (allowsText && opts.role === 'hook' && opts.indexInSegment === 0) { + return { + communicationIntent: `首屏用短屏幕文字建立抓停点:${screenTextIntent}`, + copyMode: 'screen_text', + copyRequired: true, + copyPurpose: '抓停和定义观看问题。', + screenTextIntent, + }; + } + + if (opts.assetNeed.includes('talking_head')) { + return { + communicationIntent: `${roleName(opts.role)} 需要口播解释:${screenTextIntent}`, + copyMode: 'voiceover', + copyRequired: true, + copyPurpose: `用口播把 ${point} 讲清楚。`, + screenTextIntent, + }; + } + + if (allowsText && opts.role === 'closing') { + return { + communicationIntent: `结尾用短字幕收束记忆点:${screenTextIntent}`, + copyMode: 'screen_text', + copyRequired: true, + copyPurpose: '给观众留下可记住的结束句。', + screenTextIntent, + }; + } + + if (allowsText && opts.indexInSegment === 0 && (!opts.hasPreferredAsset || opts.role === 'setup' || opts.role === 'develop' || opts.role === 'climax')) { + return { + communicationIntent: `${roleName(opts.role)} 首镜用一句轻字幕说明观看目的:${screenTextIntent}`, + copyMode: 'subtitle', + copyRequired: true, + copyPurpose: `说明 ${point},但保持短句。`, + screenTextIntent, + }; + } + + return { + communicationIntent: allowsText + ? `${roleName(opts.role)} 的第 ${opts.indexInSegment + 1}/${opts.sliceCount} 镜只靠画面、动作或转场推进,不加字幕或口播。` + : `${roleName(opts.role)} 沿用样例的低字幕 / 无字幕风格,只靠画面与剪辑表达,不加上屏文字。`, + copyMode: 'none', + copyRequired: false, + screenTextIntent: 'visual-only:不需要上屏文字或口播', + }; +} + +function directorMustShow( + topic: string, + sellingPoints: string[], + role: z.infer, + assetNeed: TaggedAsset['assetTags'], +): string { + const point = sellingPoints[0] ?? topic; + if (assetNeed.includes('product_closeup')) return `${topic} 的主体或产品细节`; + if (assetNeed.includes('usage_demo')) return `${topic} 的使用过程或结果变化`; + if (assetNeed.includes('comparison')) return `${topic} 的前后差异或证明`; + if (role === 'hook') return `${topic} 的第一眼冲突或反差`; + if (role === 'closing') return `${topic} 的结尾记忆点:${point}`; + return `${topic} 的关键信息:${point}`; +} + +function directorMotionPreset(opts: { + density: string; + role: z.infer; + visualRole: z.infer; + shotScale: z.infer; + index: number; + indexInSegment: number; + hasPreferredAsset: boolean; + segmentMotionIntent?: z.infer; +}): z.infer { + if (opts.segmentMotionIntent && opts.segmentMotionIntent !== 'static') return opts.segmentMotionIntent; + if (!opts.hasPreferredAsset && (opts.visualRole === 'transition_card' || opts.visualRole === 'cta_card')) return 'static'; + if (opts.visualRole === 'transition_card' || opts.visualRole === 'cta_card') return 'static'; + if (opts.role === 'hook' && opts.indexInSegment === 0) return opts.density === 'high' ? 'snap_zoom' : 'push_in'; + if (opts.role === 'closing') return 'push_out'; + if (opts.visualRole === 'detail' || opts.visualRole === 'proof' || opts.shotScale === 'close') { + return opts.index % 4 === 0 && opts.density === 'high' ? 'beat_pulse' : opts.index % 3 === 0 ? 'tilt_in' : 'push_in'; + } + if (opts.visualRole === 'establishing' || opts.shotScale === 'wide') { + const wideMotions: Array> = ['pan_left', 'pan_right', 'pan_up', 'parallax_drift', 'reveal_pan']; + return wideMotions[opts.index % wideMotions.length]; + } + const motions: Array> = + opts.density === 'high' + ? ['pan_left', 'push_in', 'pan_right', 'pan_down', 'snap_zoom', 'beat_pulse', 'reveal_pan'] + : ['pan_left', 'push_in', 'pan_right', 'push_out', 'parallax_drift']; + return motions[opts.index % motions.length]; +} + +function directorCropPreset( + role: z.infer, + index: number, + shotScale: z.infer = 'medium', +): DirectorCropPreset { + if (shotScale === 'close') return 'closeup'; + if (shotScale === 'wide') return 'center'; + if (role === 'hook' || role === 'climax') return 'closeup'; + if (role === 'closing') return 'center'; + const presets: DirectorCropPreset[] = ['center', 'left', 'right', 'top']; + return presets[index % presets.length]; +} + +function directorTransitionPreset(opts: { + density: string; + index: number; + firstFallback: SourcePreference; + visualRole: z.infer; + segmentTransitionIntent?: z.infer; +}): z.infer { + if (opts.index === 1) return 'cut'; + if (opts.segmentTransitionIntent) return opts.segmentTransitionIntent; + if (opts.firstFallback === 'text_card' || opts.firstFallback === 'packaging_overlay') return 'crossfade'; + if (opts.visualRole === 'transition_card' || opts.visualRole === 'cta_card') return 'crossfade'; + if (opts.density === 'high') return opts.index % 4 === 0 ? 'snap_cut' : 'cut'; + return opts.index % 5 === 0 ? 'crossfade' : 'cut'; +} + +function hookScore(clarity: number, curiosity: number, audienceFit: number, visualPotential: number): HookScore { + const total = round((clarity * 0.3 + curiosity * 0.25 + audienceFit * 0.25 + visualPotential * 0.2) * 10); + return { clarity, curiosity, audienceFit, visualPotential, total }; +} + +function packagingPresetFor(style: CardStylePreset): DirectorEditConstraints['packagingPreset'] { + if (style === 'social_punch' || style === 'sticker_pop' || style === 'cover_card') return 'punchy_social'; + if (style === 'lifestyle_story' || style === 'editorial_caption') return 'lifestyle_story'; + return 'clean_product'; +} + +function compactHook(text: string): string { + return text.replace(/\s+/g, '').slice(0, 18); +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +function shortText(text: string, max: number): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized; +} + +function continueText(role: z.infer, topic: string): string { + const map: Record, string> = { + hook: `${topic} 的反差`, + setup: '问题和背景', + develop: '核心信息推进', + climax: '重点 / 反转', + closing: '结尾记忆点', + }; + return map[role]; +} + +function roleName(role: z.infer): string { + const map: Record, string> = { + hook: '开场抓人', + setup: '铺垫背景', + develop: '主体展开', + climax: '高潮重点', + closing: '收尾表达', + }; + return map[role]; +} + +function visualRoleName(role: z.infer): string { + const map: Record, string> = { + establishing: '建立场景', + person_in_scene: '人在场景中', + detail: '细节特写', + action: '过程动作', + proof: '证明镜头', + b_roll: '氛围补充', + transition_card: '信息转场', + cta_card: '收尾行动', + }; + return map[role]; +} + +function shotScaleName(scale: z.infer): string { + const map: Record, string> = { + wide: '远景', + medium: '中景', + close: '近景', + macro: '微距', + }; + return map[scale]; +} + +function purposeForRole(role: z.infer, index: number, total: number): string { + if (total > 1 && index > 0) return `${roleName(role)}:补充新信息或新画面变化`; + const map: Record, string> = { + hook: '前 3 秒抓住注意力,建立继续观看的理由', + setup: '交代背景或问题,让观众理解接下来为什么重要', + develop: '展开主体信息,持续给出新证据或新视角', + climax: '放大最有冲击力的重点、反转或证明', + closing: '收束记忆点,给观众明确的结束感', + }; + return map[role]; +} + +function audioCueForRole(role: z.infer, density: string): string { + if (role === 'hook') return 'hit / snap'; + if (role === 'climax') return 'rise / drop'; + return density === 'high' ? 'tick / beat cut' : 'soft whoosh'; +} + +function goalForGenre(genre: string): string { + if (genre === 'product') return '建立信任并促进转化'; + if (genre === 'tutorial') return '让观众快速理解并愿意收藏'; + if (genre === 'narrative') return '让观众跟随故事并记住转折'; + return '提升完播与记忆点'; +} + +function toneForGenre(genre: string): string { + if (genre === 'product') return '清晰、有说服力、克制'; + if (genre === 'tutorial') return '直接、信息密度高、节奏明确'; + if (genre === 'narrative') return '有悬念、有情绪推进、收束有力'; + return '清楚、紧凑、有观看推进'; +} + +function findStoryboard( + storyboard: StoryboardItem[], + role: z.infer, + startSec: number, + endSec: number, +): StoryboardItem | undefined { + return storyboard.find((s) => s.segmentRole === role && rangesOverlap(s.startSec, s.endSec, startSec, endSec)); +} + +function findBeat(beats: Beat[], item: TimelineItem): Beat | undefined { + return beats.find((b) => rangesOverlap(b.startSec, b.endSec, item.startSec, item.endSec)) ?? beats[0]; +} + +function findScriptLine(script: ScriptLine[], startSec: number, endSec: number): ScriptLine | undefined { + return script.find((line) => rangesOverlap(line.startSec, line.endSec, startSec, endSec)); +} + +function visibleCopy(line: ScriptLine): string { + return line.screenText || line.cardCopy || line.voiceoverScript || line.text; +} + +function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { + return Math.max(aStart, bStart) < Math.min(aEnd, bEnd); +} + +function subjectForBeat(topic: string, role: z.infer, assetId?: string): string { + if (assetId) return `匹配素材 ${assetId} 中的主体`; + if (role === 'hook') return `${topic} 的高反差画面或标题卡`; + if (role === 'closing') return `${topic} 的总结画面`; + return `${topic} 的关键素材或说明画面`; +} + +function actionForBeat(role: z.infer, visualChange: string): string { + if (role === 'hook') return `快速进入主题:${shortText(visualChange, 24)}`; + if (role === 'climax') return `放大重点:${shortText(visualChange, 24)}`; + return `承接信息推进:${shortText(visualChange, 24)}`; +} + +function compositionForRole(role: z.infer): string { + if (role === 'hook') return '中心主体 + 大标题,优先保证首帧可读'; + if (role === 'closing') return '主体居中,留出结尾字幕安全区'; + return '主体与字幕分层,避免画面信息互相遮挡'; +} + +function motionForDensity(density: string, index: number): string { + if (density === 'high') return index % 2 === 0 ? '快速 push-in / snap zoom' : '横向切换 / beat cut'; + if (density === 'medium') return index % 2 === 0 ? '轻微 push-in' : '平移或裁切变化'; + return '稳定镜头 + 轻微 Ken Burns'; +} + +function sourcePreference(match: SlotMatch | undefined, fill: FillArtifact | undefined): SourcePreference { + if (match?.status === 'matched') return 'user_asset'; + if (fill?.kind === 'copy_completion') return 'copy_completion'; + if (fill?.kind === 'text_card') return 'text_card'; + if (fill?.kind === 'packaging_overlay') return 'packaging_overlay'; + if (fill?.kind === 'reference_clip') return 'reference_clip'; + if (fill?.kind === 'reused_clip') return 'reused_clip'; + if (fill?.kind === 'aigc_clip' || fill?.kind === 'aigc_image') return 'aigc'; + return 'text_card'; +} + +function sourceRef(item: TimelineItem): string | undefined { + if (item.source.kind === 'user_asset') return item.source.assetId; + if (item.source.kind === 'fill_artifact') return item.source.fillArtifactId; + return item.source.path; +} + +function overlayForItem(input: BuildDirectorArtifactsInput, item: TimelineItem): string | undefined { + const segment = input.script.find((line) => rangesOverlap(line.startSec, line.endSec, item.startSec, item.endSec)); + if (!segment) return undefined; + if (segment.segmentRole === 'hook') return input.blueprint.packagingStructure?.titleBarStyle ?? '强标题'; + if (segment.segmentRole === 'climax') return input.blueprint.packagingStructure?.stickerUsage ?? '关键词强调'; + return undefined; +} + +function transitionForIndex(density: string, index: number): string { + if (index === 0) return 'opening_cut'; + if (density === 'high') return index % 2 === 0 ? 'snap_cut' : 'whip_cut'; + if (density === 'medium') return 'clean_cut'; + return 'soft_cut'; +} + +function sumFillDuration(timeline: Timeline, fills: FillArtifact[], kind: FillArtifact['kind']): number { + const fillById = new Map(fills.map((f) => [f.id, f])); + return timeline.items.reduce((acc, item) => { + if (item.source.kind !== 'fill_artifact') return acc; + const fill = fillById.get(item.source.fillArtifactId); + if (fill?.kind !== kind) return acc; + return acc + Math.max(0, item.endSec - item.startSec); + }, 0); +} + +function sumGeneratedCardDuration(timeline: Timeline, fills: FillArtifact[]): number { + return timeline.items.reduce((acc, item) => { + if (!isTimelineGeneratedCard(item, fills)) return acc; + return acc + Math.max(0, item.endSec - item.startSec); + }, 0); +} + +function isTimelineGeneratedCard(item: TimelineItem, fills: FillArtifact[]): boolean { + const source = item.source; + if (source.kind !== 'fill_artifact') return false; + return fills.some((f) => f.id === source.fillArtifactId && f.source.startsWith('textcard://')); +} + +function longestRepeatedSourceRun(timeline: Timeline): number { + const visualItems = timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec); + let longest = 0; + let currentKey = ''; + let currentStart = 0; + let currentEnd = 0; + + for (const item of visualItems) { + const key = sourceRef(item) ?? item.source.kind; + if (key !== currentKey || Math.abs(item.startSec - currentEnd) > 0.05) { + longest = Math.max(longest, currentEnd - currentStart); + currentKey = key; + currentStart = item.startSec; + } + currentEnd = item.endSec; + } + return Math.max(longest, currentEnd - currentStart); +} + +function executableShotRatio(shotList: ShotList, assetPlan: AssetPlan): number { + const planByShot = new Map(assetPlan.items.map((item) => [item.shotId, item])); + const executable = shotList.shots.filter((shot) => { + const plan = planByShot.get(shot.shotId); + return Boolean(plan?.matchedAssetId || plan?.fillArtifactId || shot.sourcePreference === 'text_card'); + }).length; + return shotList.shots.length ? executable / shotList.shots.length : 0; +} + +function storyFunctionCoverage(input: BuildDirectorArtifactsInput): number { + const shots = input.directorPlan?.shots ?? []; + if (!shots.length) return 1; + const assetById = new Map(input.assets.map((asset) => [asset.id, asset])); + const itemByShot = new Map(input.timeline.items.filter((item) => item.track !== 'audio').map((item) => [item.shotRef, item])); + const keyFunctions = new Set(['opening_hook', 'context', 'character', 'action', 'detail', 'contrast', 'proof', 'turn', 'payoff', 'cta']); + const relevant = shots.filter((shot) => keyFunctions.has(shot.storyFunction)); + if (!relevant.length) return 1; + const covered = relevant.filter((shot) => { + const item = itemByShot.get(shot.shotId); + if (!item) return false; + if (item.source.kind === 'fill_artifact') return shot.storyFunction !== 'proof' && shot.storyFunction !== 'turn'; + if (item.source.kind !== 'user_asset') return true; + const asset = assetById.get(item.source.assetId); + if (!asset) return false; + const storyRoles = asset.storyRoles ?? []; + if (!storyRoles.length) { + if (shot.storyFunction === 'proof' || shot.storyFunction === 'turn') { + const functions = asset.visualFunctions ?? []; + return ( + asset.assetTags.some((tag) => tag === 'comparison' || tag === 'usage_demo') || + functions.some((fn) => fn === 'show_result' || fn === 'show_progression') + ); + } + if (shot.storyFunction === 'payoff' || shot.storyFunction === 'cta') { + const functions = asset.visualFunctions ?? []; + return functions.some((fn) => fn === 'show_result' || fn === 'call_to_action'); + } + return shot.assetNeed.some((tag) => asset.assetTags.includes(tag) || tag === 'b_roll'); + } + return storyRoles.includes(shot.storyFunction) || asset.narrativeUse === shot.storyFunction; + }).length; + return covered / relevant.length; +} + +function weightedTotal(scores: QCScores): number { + return clampScore( + scores.hookStrength * 0.12 + + scores.structureClarity * 0.11 + + scores.storyClosure * 0.1 + + scores.pacingChange * 0.11 + + scores.shotExecutability * 0.09 + + scores.assetCoverage * 0.1 + + scores.captionsPackaging * 0.07 + + scores.outputWatchability * 0.12 + + scores.renderReadiness * 0.08 + + scores.explainability * 0.06 + + scores.riskControl * 0.04, + ); +} + +function clampScore(score: number): number { + return round(Math.max(0, Math.min(100, score))); +} + +function targetAgentForModule(module: QCModule): string { + const map: Record = { + hook: 'CreativeDirectorAgent', + structure: 'StructureAgent', + pacing: 'VideoDirectorAgent', + shot: 'VideoDirectorAgent', + asset: 'AssetProducerAgent', + caption: 'EditorAgent', + watchability: 'QCReviewerAgent', + render: 'RenderAgent', + explainability: 'QCReviewerAgent', + risk: 'AssetProducerAgent', + }; + return map[module]; +} + +function artifactForModule(module: QCModule): string { + const map: Record = { + hook: 'creativeBrief', + structure: 'VideoStructureBlueprint', + pacing: 'beatMap', + shot: 'shotList', + asset: 'assetPlan', + caption: 'editDecisionList', + watchability: 'timeline', + render: 'renderReport', + explainability: 'evidence', + risk: 'assetPlan', + }; + return map[module]; +} + +function acceptanceForIssue(issue: QCIssue): string { + if (issue.module === 'asset') return '关键 shot 均有 matchedAssetId 或 fillArtifactId,且 high severity gap 清零。'; + if (issue.module === 'pacing') return '最长 beat 不超过 3.5s,并说明每个 beat 的观看目的。'; + if (issue.module === 'shot') return '文字卡占比降到 50% 以下,或明确改为动态包装 / 真实素材。'; + if (issue.module === 'hook') return 'selected hook 分数 >= 80,且说明观众为什么继续看。'; + return 'QC 对应分项达到 80 分以上。'; +} diff --git a/apps/api/src/core/enums.ts b/apps/api/src/core/enums.ts index 8116bf3..87f246e 100644 --- a/apps/api/src/core/enums.ts +++ b/apps/api/src/core/enums.ts @@ -33,14 +33,65 @@ export type CutDensity = z.infer; export const SubtitleDensity = z.enum(['sparse', 'medium', 'dense']); export type SubtitleDensity = z.infer; -export const MediaType = z.enum(['video', 'image', 'text']); +export const MediaType = z.enum(['video', 'image', 'text', 'audio']); export type MediaType = z.infer; +/** 叙事功能:shot / 素材在故事闭环里承担什么职责。 */ +export const StoryFunction = z.enum([ + 'opening_hook', + 'context', + 'character', + 'action', + 'detail', + 'contrast', + 'proof', + 'turn', + 'payoff', + 'cta', + 'mood', + 'transition', +]); +export type StoryFunction = z.infer; + +/** 通用视觉叙事功能:跨体裁描述一个画面在故事里解决什么问题。 */ +export const VisualFunction = z.enum([ + 'establish_context', + 'introduce_subject', + 'show_action', + 'show_detail', + 'show_progression', + 'show_result', + 'show_emotion', + 'show_scale', + 'bridge_transition', + 'call_to_action', +]); +export type VisualFunction = z.infer; + +/** 本次迁移主要学习样例的哪一层能力。 */ +export const MigrationIntent = z.enum(['story_only', 'editing_only', 'story_and_editing']); +export type MigrationIntent = z.infer; + +/** 优质案例 / 样例视频画面是否允许进入最终 timeline。 */ +export const ReferenceClipMode = z.enum(['learn_only', 'allow_reference_clip']); +export type ReferenceClipMode = z.infer; + +/** 素材不足时如何改导演方案和补视觉缺口。 */ +export const VisualGapMode = z.enum(['user_only', 'smart_fill', 'reference_bridge']); +export type VisualGapMode = z.infer; + +/** 样例画幅模板与用户素材画幅冲突时如何处理。 */ +export const TemplateAdaptationMode = z.enum(['auto', 'preserve_sample_frame', 'portrait_safe']); +export type TemplateAdaptationMode = z.infer; + /** 缺口补全产物的种类。 */ export const FillKind = z.enum([ 'reused_clip', + 'reference_clip', + 'copy_completion', 'text_card', 'packaging_overlay', + 'stock_clip', 'aigc_clip', 'aigc_image', ]); diff --git a/apps/api/src/core/index.ts b/apps/api/src/core/index.ts index a6a1b1e..c65df79 100644 --- a/apps/api/src/core/index.ts +++ b/apps/api/src/core/index.ts @@ -4,8 +4,13 @@ export * from './explain'; export * from './blueprint'; export * from './slot'; export * from './sample'; +export * from './sampleLearning'; export * from './timeline'; +export * from './template'; +export * from './director'; export * from './migration'; export * from './patch'; +export * from './applyPatch'; +export * from './versions'; export * from './validate'; export * from './jsonSchema'; diff --git a/apps/api/src/core/jsonSchema.ts b/apps/api/src/core/jsonSchema.ts index a80408b..8b1f5fe 100644 --- a/apps/api/src/core/jsonSchema.ts +++ b/apps/api/src/core/jsonSchema.ts @@ -2,9 +2,21 @@ import { zodToJsonSchema } from 'zod-to-json-schema'; import { VideoStructureBlueprint } from './blueprint'; import { FillArtifact, Gap, StructureSlot, TaggedAsset } from './slot'; import { SampleAnalysis } from './sample'; +import { LearnedSamplePattern, SampleLearningDraft } from './sampleLearning'; import { Timeline } from './timeline'; import { BlueprintPatch } from './patch'; import { MigrationPlan } from './migration'; +import { MigrationControls, MigrationSignalInputs } from './migrationControls'; +import { + AssetPlan, + BeatMap, + CreativeBrief, + DirectorPlan, + EditDecisionList, + QCReport, + RevisionPlan, + ShotList, +} from './director'; /** * JSON Schema 视图,供 LLM 结构化输出 / function calling 约束 StructureAgent 等使用。 @@ -13,12 +25,24 @@ import { MigrationPlan } from './migration'; export const jsonSchemas = { VideoStructureBlueprint: zodToJsonSchema(VideoStructureBlueprint, 'VideoStructureBlueprint'), SampleAnalysis: zodToJsonSchema(SampleAnalysis, 'SampleAnalysis'), + SampleLearningDraft: zodToJsonSchema(SampleLearningDraft, 'SampleLearningDraft'), + LearnedSamplePattern: zodToJsonSchema(LearnedSamplePattern, 'LearnedSamplePattern'), StructureSlot: zodToJsonSchema(StructureSlot, 'StructureSlot'), TaggedAsset: zodToJsonSchema(TaggedAsset, 'TaggedAsset'), Gap: zodToJsonSchema(Gap, 'Gap'), FillArtifact: zodToJsonSchema(FillArtifact, 'FillArtifact'), Timeline: zodToJsonSchema(Timeline, 'Timeline'), + CreativeBrief: zodToJsonSchema(CreativeBrief, 'CreativeBrief'), + DirectorPlan: zodToJsonSchema(DirectorPlan, 'DirectorPlan'), + BeatMap: zodToJsonSchema(BeatMap, 'BeatMap'), + ShotList: zodToJsonSchema(ShotList, 'ShotList'), + AssetPlan: zodToJsonSchema(AssetPlan, 'AssetPlan'), + EditDecisionList: zodToJsonSchema(EditDecisionList, 'EditDecisionList'), + QCReport: zodToJsonSchema(QCReport, 'QCReport'), + RevisionPlan: zodToJsonSchema(RevisionPlan, 'RevisionPlan'), MigrationPlan: zodToJsonSchema(MigrationPlan, 'MigrationPlan'), + MigrationControls: zodToJsonSchema(MigrationControls, 'MigrationControls'), + MigrationSignalInputs: zodToJsonSchema(MigrationSignalInputs, 'MigrationSignalInputs'), BlueprintPatch: zodToJsonSchema(BlueprintPatch, 'BlueprintPatch'), } as const; diff --git a/apps/api/src/core/migration.ts b/apps/api/src/core/migration.ts index 6c154f5..56f7bb1 100644 --- a/apps/api/src/core/migration.ts +++ b/apps/api/src/core/migration.ts @@ -1,10 +1,50 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import type { VideoStructureBlueprint } from './blueprint'; -import { SegmentRole } from './enums'; +import { buildDirectorArtifacts, buildDirectorPlan, buildSampleStoryArc, DirectorArtifacts, DirectorPlan, type DirectorPlanShot, type SourcePreference } from './director'; +import { + MigrationIntent, + ReferenceClipMode, + SegmentRole, + TemplateAdaptationMode, + VisualGapMode, + VisualFunction, + type StoryFunction, + type VisualFunction as VisualFunctionT, +} from './enums'; import { Decision, Evidence } from './explain'; -import { Gap, FillArtifact, type StructureSlot, type TaggedAsset } from './slot'; -import { Timeline, type TimelineItem } from './timeline'; +import { + buildRhythmAlignmentPlan, + buildPacingEnvelopeBlend, + createMusicFingerprintFromGrid, + createRhythmProfileFromBlueprint, + nearestBeatAnchor, + rhythmCutAnchors, + rhythmProfileFromPattern, + type PacingEnvelope, + type RhythmAlignmentPlan, +} from './rhythm'; +import { MigrationControls, type MigrationControls as MigrationControlsT, type MigrationControlsInput } from './migrationControls'; +import type { SampleAnalysis } from './sample'; +import { withInferredLearningQualityTags, type LearnedSamplePattern } from './sampleLearning'; +import { sanitizeViewerCopy } from './semanticCopy'; +import { Gap, FillArtifact, type ReferenceAsset, type StructureSlot, type TaggedAsset } from './slot'; +import { isActionableTemplateProfile, isCarouselTemplateProfile, type TemplateProfile } from './template'; +import { + BeatGrid, + Timeline, + type CardAnimationPreset, + type CardStylePreset, + type CropPreset, + type FramePolicy, + type MotionPreset, + type TimelineItem, + type TimelineSource, + type TransitionPreset, +} from './timeline'; +import { matchSlot, reusableAssets } from './migration/assetMatching'; + +type StandardCropPreset = Exclude; export const SlotMatch = z.object({ slotId: z.string(), @@ -15,21 +55,70 @@ export const SlotMatch = z.object({ }); export type SlotMatch = z.infer; +export const VisualCoverageItem = z.object({ + visualFunction: VisualFunction, + requiredShots: z.number().int().nonnegative(), + coveredAssets: z.number().int().nonnegative(), + status: z.enum(['covered', 'weak', 'missing']), + note: z.string(), +}); +export type VisualCoverageItem = z.infer; + +export const VisualCoverageReport = z.object({ + requiredFunctions: z.array(VisualFunction).default([]), + coveredFunctions: z.array(VisualFunction).default([]), + missingFunctions: z.array(VisualFunction).default([]), + weakFunctions: z.array(VisualFunction).default([]), + detailItems: z.array(VisualCoverageItem).default([]), + closeUpLikeShare: z.number().min(0).max(1).default(0), + aspectMismatch: z.boolean().default(false), + referenceClipAllowed: z.boolean().default(false), + recommendation: z.string().default(''), +}); +export type VisualCoverageReport = z.infer; + +export const VisualGapPolicy = z.object({ + mode: VisualGapMode, + adaptationMode: z.enum(['faithful_transfer', 'adapted_transfer', 'packaged_story', 'needs_capture']), + referenceClipUse: z.enum(['none', 'bridge_only']).default('none'), + maxReferenceShare: z.number().min(0).max(1).default(0.12), + maxReferenceClipSec: z.number().positive().default(1.2), + rationale: z.string().default(''), +}); +export type VisualGapPolicy = z.infer; + +type LowMaterialExpansionPlan = { + enabled: boolean; + targetShotCount: number; + allowVideoMotion: boolean; + relaxTotalShare: boolean; + reason: string; +}; + export const ScriptLine = z.object({ segmentRole: SegmentRole, startSec: z.number().min(0), endSec: z.number().min(0), - text: z.string(), + /** 兼容旧 UI 的聚合可读文案;渲染层不得把它直接当成字幕。 */ + text: z.string().default(''), + voiceoverScript: z.string().default(''), + screenText: z.string().default(''), + cardCopy: z.string().default(''), }); export type ScriptLine = z.infer; export const StoryboardItem = z.object({ + shotId: z.string().optional(), segmentRole: SegmentRole, slotId: z.string().optional(), startSec: z.number().min(0), endSec: z.number().min(0), visual: z.string(), - copy: z.string(), + /** 兼容旧 UI 的聚合可读文案;具体渲染按下方显式字段消费。 */ + copy: z.string().default(''), + voiceoverScript: z.string().default(''), + screenText: z.string().default(''), + cardCopy: z.string().default(''), }); export type StoryboardItem = z.infer; @@ -45,6 +134,16 @@ export const MigrationPlan = z.object({ script: z.array(ScriptLine), storyboard: z.array(StoryboardItem), timeline: Timeline, + visualCoverage: VisualCoverageReport.optional(), + visualGapPolicy: VisualGapPolicy.optional(), + directorPlan: DirectorPlan, + creativeBrief: DirectorArtifacts.shape.creativeBrief, + beatMap: DirectorArtifacts.shape.beatMap, + shotList: DirectorArtifacts.shape.shotList, + assetPlan: DirectorArtifacts.shape.assetPlan, + editDecisionList: DirectorArtifacts.shape.editDecisionList, + qcReport: DirectorArtifacts.shape.qcReport, + revisionPlan: DirectorArtifacts.shape.revisionPlan, evidence: z.array(Evidence).default([]), decisions: z.array(Decision).default([]), rationale: z.string().default(''), @@ -55,47 +154,327 @@ export interface RuleBasedMigrationInput { projectId: string; sampleId: string; blueprint: VideoStructureBlueprint; + /** 当前样例的机器分析结果;用于把 templateProfile 传入 timeline。 */ + sampleAnalysis?: SampleAnalysis; assets: TaggedAsset[]; + /** 低优先级参考素材:当前样例视频或已学习优质样例视频,只有补全策略不足时才复用。 */ + referenceAssets?: ReferenceAsset[]; + learnedPatterns?: LearnedSamplePattern[]; + /** 由 LLM DirectorAgent 或外部导演层产出的执行契约;提供后 migration 必须按它生成 timeline。 */ + directorPlan?: DirectorPlan; topic: string; sellingPoints?: string[]; durationSec?: number; + /** 这次主要迁移故事叙述、剪辑手法,还是两者都迁移。 */ + migrationIntent?: z.infer; + /** 优质案例 / 样例视频是否允许作为 reference_clip 进入视觉时间线。 */ + referenceClipMode?: z.infer; + /** 素材不足时采用保守重排、智能补全,还是短参考桥接。 */ + visualGapMode?: z.infer; + /** 样例画幅模板和用户素材画幅冲突时的适配策略。 */ + templateAdaptationMode?: z.infer; + /** 是否把上传的音频素材或视频原声复用为 timeline BGM。默认开启。 */ + reuseUploadedBgm?: boolean; + /** API 层基于真实音频文件检测出的 beat grid;没有时 core 会估算。 */ + detectedBeatGrid?: BeatGrid; + /** UI / API 可控项与外部分析信号;ASR、音乐段落、clip scoring、safe crop 都从这里进入 core。 */ + migrationControls?: MigrationControlsInput; +} + +const REUSED_ASSET_URI = 'asset://'; +const MAX_REFERENCE_SHARE = 0.12; +const MAX_REFERENCE_CLIP_SEC = 1.6; +const MIN_REFERENCE_RETAIN_SEC = 0.5; +const REFERENCE_OPENING_SAFE_SKIP_SEC = 2.4; +const REFERENCE_TEXTLIKE_SAFE_SKIP_SEC = 3; +const REFERENCE_OUTRO_SAFE_SKIP_SEC = 5; +const REFERENCE_TEXTLIKE_OUTRO_SAFE_SKIP_SEC = 6; +const REFERENCE_SOURCE_SNAP_SEC = 0.5; +const MAX_FULL_SCREEN_TEXT_CARD_SHARE = 0.12; +const MIN_FULL_SCREEN_TEXT_CARD_BUDGET_SEC = 1.2; +const REFERENCE_BRIDGE_ALLOWED_FUNCTIONS = new Set([ + 'establish_context', + 'show_scale', + 'show_emotion', + 'bridge_transition', +]); +const REFERENCE_BRIDGE_BLOCKED_FUNCTIONS = new Set([ + 'introduce_subject', + 'show_action', + 'show_detail', + 'show_progression', + 'show_result', + 'call_to_action', +]); +const REFERENCE_PERSONLIKE_TEXT = + /(真人|人物|人像|出镜|口播|自拍视频|自拍|近脸|脸部|面部|博主|主角|女孩|女生|男生|小女孩|小孩|儿童|游客|行人|直面镜头|人物.*互动|互动.*人物|talking[_ -]?head|person|people|human|portrait|face|selfie|vlogger|creator|influencer|girl|boy|woman|man|tourist|pedestrian)/i; +const REFERENCE_PERSONLIKE_CLUSTER = /(human|person|people|portrait|face|selfie|talking|tourist|pedestrian)/i; +const REFERENCE_PLATFORM_TEXT = + /(平台|账号|帐号|用户名|搜索|关注|主页|粉丝|私信|二维码|水印|抖音|快手|小红书|视频号|创作者|作者|来抖音|douyin|tiktok|kuaishou|xiaohongshu|rednote|watermark|handle|username|account|follow|subscribe|creator)/i; +const SILENT_AUDIO_MAX_VOLUME_DB = -60; + +interface RepetitionGuardState { + currentKey: string; + currentRunSec: number; + totalSecByKey: Map; + currentClusterKey: string; + currentClusterRunSec: number; + totalSecByClusterKey: Map; + totalSecByVisualFunctionKey: Map; + storyFunctionAssetByKey: Map; + storyFunctionClusterByKey: Map; + guardFillCount: number; + replacements: Array<{ + originalKey: string; + replacementKey: string; + reason: 'continuous_run' | 'total_share'; + startSec: number; + endSec: number; + }>; } -const MIN_CONFIDENCE = 0.3; +type SourceWindowCursor = Map; +type CopyFields = Pick; +type ShotSpan = { startSec: number; endSec: number }; + +function resolveVisualGapMode( + visualGapMode: z.infer | undefined, + referenceClipMode: z.infer | undefined, +): z.infer { + if (visualGapMode) return visualGapMode; + return referenceClipMode === 'allow_reference_clip' ? 'reference_bridge' : 'smart_fill'; +} + +function buildVisualGapPolicy(opts: { + mode: z.infer; + visualCoverage: VisualCoverageReport; + assets: TaggedAsset[]; + directorPlan: DirectorPlan; + referenceAssets: ReferenceAsset[]; +}): VisualGapPolicy { + const visualAssetCount = reusableAssets(opts.assets).length; + const hasCriticalMissing = opts.visualCoverage.missingFunctions.some((fn) => + ['show_result', 'call_to_action', 'introduce_subject'].includes(fn), + ); + const hasCaptureCriticalMissing = opts.visualCoverage.missingFunctions.some((fn) => + ['show_result', 'call_to_action'].includes(fn), + ); + const hasAnyGap = opts.visualCoverage.missingFunctions.length > 0 || opts.visualCoverage.weakFunctions.length > 0; + const adaptationMode: VisualGapPolicy['adaptationMode'] = + hasCaptureCriticalMissing || (hasCriticalMissing && visualAssetCount === 0) + ? 'needs_capture' + : opts.visualCoverage.missingFunctions.length >= 3 || visualAssetCount <= 1 || opts.visualCoverage.closeUpLikeShare > 0.72 + ? 'packaged_story' + : hasAnyGap + ? 'adapted_transfer' + : 'faithful_transfer'; + const referenceClipUse = opts.mode === 'reference_bridge' ? 'bridge_only' : 'none'; + const modeLabel = + opts.mode === 'user_only' + ? '只用用户素材,并通过重排 / 文案 / 包装降低缺口影响' + : opts.mode === 'smart_fill' + ? '优先把样例方法转译为用户素材重组、包装和可生成补位' + : '只在氛围、尺度、转场等可桥接功能缺失时短暂使用参考画面'; + const rationale = `${modeLabel};素材预算形态=${adaptationMode};缺失=${opts.visualCoverage.missingFunctions.join('/') || 'none'};弱覆盖=${opts.visualCoverage.weakFunctions.join('/') || 'none'};可用参考素材=${opts.referenceAssets.filter((asset) => asset.sourcePath).length}`; + return VisualGapPolicy.parse({ + mode: opts.mode, + adaptationMode, + referenceClipUse, + maxReferenceShare: MAX_REFERENCE_SHARE, + maxReferenceClipSec: MAX_REFERENCE_CLIP_SEC, + rationale, + }); +} export function runRuleBasedMigration(input: RuleBasedMigrationInput): MigrationPlan { - const durationSec = Math.max(6, round(input.durationSec ?? 30)); const sellingPoints = (input.sellingPoints ?? []).map((p) => p.trim()).filter(Boolean); const topic = input.topic.trim() || '新主题'; - - const matches: SlotMatch[] = input.blueprint.slots.map((slot) => - matchSlot(slot, input.assets), + const migrationControls = MigrationControls.parse(input.migrationControls ?? {}); + const assets = input.assets.map((asset) => enrichTaggedAssetForStory(applyMigrationSignalsToAsset(asset, migrationControls))); + const migrationIntent = input.migrationIntent ?? 'story_and_editing'; + const visualGapMode = resolveVisualGapMode(input.visualGapMode, input.referenceClipMode); + const referenceClipMode = visualGapMode === 'reference_bridge' ? 'allow_reference_clip' : 'learn_only'; + const templateAdaptationMode = input.templateAdaptationMode ?? 'auto'; + const allowReferenceClips = visualGapMode === 'reference_bridge'; + const rankContext = { topic, sellingPoints }; + const rankedPatterns = rankLearnedPatterns(input.learnedPatterns ?? [], input.blueprint, { + ...rankContext, + purpose: 'story', + }); + const rankedEditingPatterns = rankLearnedPatterns(input.learnedPatterns ?? [], input.blueprint, { + ...rankContext, + purpose: 'editing', + }); + const selectedPattern = selectLearnedPatternForControls(rankedPatterns, migrationControls); + const selectedEditingPattern = selectLearnedPatternForControls(rankedEditingPatterns, migrationControls); + const referenceAssets = input.referenceAssets ?? []; + const visualReferenceAssets = allowReferenceClips + ? filterReferenceAssetsForVisualBridge(referenceAssets, topic, sellingPoints) + : []; + const filteredReferenceAssetIds = new Set(visualReferenceAssets.map((asset) => asset.id)); + const excludedVisualReferenceAssets = allowReferenceClips + ? referenceAssets.filter((asset) => !filteredReferenceAssetIds.has(asset.id)) + : []; + const editingPattern = migrationIntent === 'story_only' ? undefined : selectedEditingPattern; + const effectiveCutDensity = fasterDensity(editingPattern?.pacing.cutDensity, input.blueprint.rhythmStructure.cutDensity); + const targetShotSec = targetShotSecFor(input.blueprint, editingPattern); + const rawTemplateProfile = migrationIntent === 'story_only' + ? undefined + : chooseTemplateProfile(input.sampleAnalysis?.templateProfile, editingPattern?.templateProfile); + const aspectGuard = assessTemplateAspectGuard(rawTemplateProfile, assets, templateAdaptationMode); + const templateProfile = aspectGuard.useTemplate ? rawTemplateProfile : undefined; + const plannedDurationSec = resolveTargetDurationSec(input, editingPattern, targetShotSec, templateProfile); + const cardTreatment = cardTreatmentForPattern(editingPattern, input.blueprint); + const rawDirectorPlan = + input.directorPlan ?? + buildDirectorPlan({ + blueprint: input.blueprint, + assets, + topic, + sellingPoints, + durationSec: plannedDurationSec, + targetShotSec, + cutDensity: effectiveCutDensity, + selectedPattern, + referenceAssets: visualReferenceAssets, + migrationIntent, + referenceClipMode, + cardTreatment, + evidence: input.blueprint.evidence, + }); + const storyNormalizedDirectorPlan = migrationIntent === 'editing_only' + ? rawDirectorPlan + : normalizeDirectorPlanForStoryDefault(rawDirectorPlan, input); + const carouselDirectorPlan = expandDirectorPlanForCarousel(storyNormalizedDirectorPlan, { + templateProfile, + assets, + }); + const lowMaterialPlan = buildLowMaterialExpansionPlan({ + plan: carouselDirectorPlan, + assets, + templateProfile, + preserveStoryBeatCount: input.blueprint.videoGenre === 'narrative' && sellingPoints.length === 0, + }); + const directorPlan = expandDirectorPlanForLowMaterial(carouselDirectorPlan, lowMaterialPlan, assets); + const durationSec = directorPlan.editConstraints.durationSec; + const beatGrid = buildBeatGrid({ + durationSec, + cutDensity: directorPlan.editConstraints.cutDensity, + blueprint: input.blueprint, + selectedPattern, + detected: input.detectedBeatGrid, + }); + const sampleRhythmProfile = createRhythmProfileFromBlueprint({ + sampleId: input.sampleId, + blueprint: input.blueprint, + durationSec, + }); + const targetMusic = createMusicFingerprintFromGrid( + beatGrid, + durationSec, + directorPlan.editConstraints.cutDensity, + { sections: migrationControls.signals.musicSections }, ); + const baseRhythmPlan = buildRhythmAlignmentPlan({ + sampleProfile: sampleRhythmProfile, + targetGrid: beatGrid, + targetMusic, + learnedPatterns: input.learnedPatterns ?? [], + blueprint: input.blueprint, + }); + const rhythmMatchedPattern = input.learnedPatterns?.find( + (pattern) => pattern.id === baseRhythmPlan.matchedGlobalPattern?.patternId, + ); + const globalPacingPattern = rhythmMatchedPattern ?? editingPattern; + const pacingEnvelope = buildPacingEnvelopeBlend({ + sampleAvgShotSec: directorPlan.editConstraints.targetShotSec, + sampleCutDensity: directorPlan.editConstraints.cutDensity, + samplePeakAt: input.blueprint.rhythmStructure.peakAt, + targetMusic, + globalProfile: globalPacingPattern ? rhythmProfileFromPattern(globalPacingPattern) : undefined, + rationaleHint: templateProfile + ? `template=${templateProfile.layoutPreset}` + : globalPacingPattern + ? `globalPattern=${globalPacingPattern.reusablePatternName}` + : undefined, + }); + const rhythmPlan = { + ...baseRhythmPlan, + pacingEnvelope, + rationale: `${baseRhythmPlan.rationale};素材变化速度使用当前样例 pacing 与全局样例 pacing 加权融合。`, + } satisfies RhythmAlignmentPlan; + const spanByShotId = buildBeatSnappedShotSpans(directorPlan.shots, durationSec, beatGrid, rhythmPlan); + const visualCoverage = buildVisualCoverageReport({ + directorPlan, + assets, + aspectMismatch: aspectGuard.aspectMismatch, + referenceClipAllowed: allowReferenceClips && visualReferenceAssets.length > 0, + topic, + sellingPoints, + }); + const visualGapPolicy = buildVisualGapPolicy({ + mode: visualGapMode, + visualCoverage, + assets, + directorPlan, + referenceAssets: visualReferenceAssets, + }); + const uncoveredStrictVisualFunctions = new Set( + visualCoverage.missingFunctions.filter(isStrictVisualFunction), + ); + const uncoveredVisualFunctions = new Set(visualCoverage.missingFunctions); + const usageByAsset = new Map(); + const repetitionGuard = createRepetitionGuardState(); + const sourceWindowCursor: SourceWindowCursor = new Map(); + + const matches: SlotMatch[] = input.blueprint.slots.map((slot) => { + const match = matchSlot(slot, assets, usageByAsset); + if (match.status === 'matched' && match.assetId) incrementUse(usageByAsset, match.assetId); + return match; + }); const matchBySlot = new Map(matches.map((m) => [m.slotId, m])); + const hasReusableAsset = reusableAssets(assets).length > 0; + const hasReferenceAsset = referenceAssets.some( + (asset) => (asset.mediaType === 'video' || asset.mediaType === 'image') && Boolean(asset.sourcePath), + ); + const hasAllowedReferenceAsset = visualReferenceAssets.some( + (asset) => (asset.mediaType === 'video' || asset.mediaType === 'image') && Boolean(asset.sourcePath), + ); const gaps = input.blueprint.slots.reduce((acc, slot) => { - const match = matchBySlot.get(slot.id); - if (!match || match.status === 'matched' || slot.optional) return acc; - acc.push({ - slotId: slot.id, - reason: match.reason, - impactOnSegment: `${roleName(slot.segmentRole)} 缺少 ${slot.requiredAssetTypes.join('/')},会削弱该段的信息承载。`, - recommendedStrategies: ['text_card', 'packaging_overlay'], - }); - return acc; - }, []); + const match = matchBySlot.get(slot.id); + if (!match || match.status === 'matched' || slot.optional) return acc; + acc.push({ + slotId: slot.id, + reason: match.reason, + impactOnSegment: `${roleName(slot.segmentRole)} 缺少 ${slot.requiredAssetTypes.join('/')},会削弱该段的信息承载。`, + recommendedStrategies: recommendedStrategiesForSlot(slot, hasReusableAsset, hasAllowedReferenceAsset), + }); + acc[acc.length - 1].recommendedStrategies = strategiesForVisualGapMode( + acc[acc.length - 1].recommendedStrategies, + visualGapMode, + slot.requiredVisualFunctions ?? [], + ); + return acc; + }, []); const fills: FillArtifact[] = gaps.map((gap, i) => { const span = segmentSpan(input.blueprint, gap.slotId, durationSec); - return { - id: `fill_${safeId(gap.slotId)}_${i + 1}`, - slotId: gap.slotId, - kind: 'text_card', - source: `textcard://${encodeURIComponent(fillText(topic, sellingPoints, gap.slotId))}`, - track: 'video', + const slot = input.blueprint.slots.find((s) => s.id === gap.slotId); + return createGapFill({ + gap, + slot, + assets, + referenceAssets: visualReferenceAssets, + usageByAsset, + topic, + sellingPoints, startSec: span.startSec, endSec: span.endSec, - }; + index: i, + visualGapMode, + requiredVisualFunctions: slot?.requiredVisualFunctions ?? [], + }); }); const script: ScriptLine[] = []; @@ -106,89 +485,791 @@ export function runRuleBasedMigration(input: RuleBasedMigrationInput): Migration input.blueprint.scriptStructure.segments.forEach((segment, index) => { const isLast = index === input.blueprint.scriptStructure.segments.length - 1; const startSec = round(cursor); - const endSec = isLast ? durationSec : round(cursor + segment.durationRatio * durationSec); + const endSec = isLast + ? durationSec + : snapTimeToBeat(round(cursor + segment.durationRatio * durationSec), beatGrid, { + minSec: startSec + 0.8, + maxSec: durationSec, + }); cursor = endSec; + const copyFields = mergeCopyFields( + directorPlan.shots + .filter((shot) => shot.segmentRole === segment.role && shotNeedsCopy(shot)) + .map((shot) => copyFieldsForShot(shot, topic, sellingPoints, segment.copyPattern)), + ); + script.push({ + segmentRole: segment.role, + startSec, + endSec, + text: displayCopy(copyFields), + ...copyFields, + }); + }); - const slot = input.blueprint.slots.find((s) => s.segmentRole === segment.role); + let visualIndex = 0; + let previousImageMotion: MotionPreset | undefined; + const earlyCardTexts = new Set(); + const lockedAssetApplications: string[] = []; + directorPlan.shots.forEach((shot, index) => { + const shotSpan = spanByShotId.get(shot.shotId) ?? { startSec: shot.startSec, endSec: shot.endSec }; + const slot = shot.slotId + ? input.blueprint.slots.find((s) => s.id === shot.slotId) + : input.blueprint.slots.find((s) => s.segmentRole === shot.segmentRole); const match = slot ? matchBySlot.get(slot.id) : undefined; const fill = slot ? fills.find((f) => f.slotId === slot.id) : undefined; - const copy = scriptText(segment.role, topic, sellingPoints, segment.copyPattern); - const visual = visualText(segment.role, slot, match); + const segment = input.blueprint.scriptStructure.segments.find((s) => s.role === shot.segmentRole); + const copyFields = copyFieldsForShot(shot, topic, sellingPoints, segment?.copyPattern ?? ''); - script.push({ segmentRole: segment.role, startSec, endSec, text: copy }); storyboard.push({ - segmentRole: segment.role, + shotId: shot.shotId, + segmentRole: shot.segmentRole, slotId: slot?.id, - startSec, - endSec, - visual, - copy, + startSec: shotSpan.startSec, + endSec: shotSpan.endSec, + visual: shot.visualDirection, + copy: displayCopy(copyFields), + ...copyFields, }); - if (slot && match?.status === 'matched' && match.assetId) { - const asset = input.assets.find((a) => a.id === match.assetId); - items.push({ - id: `it_${index + 1}_${segment.role}`, - track: asset?.mediaType === 'text' ? 'text' : 'video', - startSec, - endSec, - slotRef: slot.id, - source: { kind: 'user_asset', assetId: match.assetId }, - }); - return; + let source: TimelineSource; + let track: TimelineItem['track']; + let sourceAsset: TaggedAsset | undefined; + + const shouldUseCoverageFallback = + shouldUseVisualCoverageFallbackForShot( + shot, + uncoveredStrictVisualFunctions, + uncoveredVisualFunctions, + visualGapMode, + ); + const lockedAssetId = shouldUseCoverageFallback + ? undefined + : lockedAssetIdForShot(shot, slot, assets, migrationControls); + if (lockedAssetId) lockedAssetApplications.push(`${shot.shotId}->${lockedAssetId}`); + const canUseDirectUserAsset = shot.fallbackStrategies.includes('user_asset') || !shot.assetNeed.includes('text_card'); + const preferredAssetId = shouldUseCoverageFallback + ? undefined + : (lockedAssetId ?? (canUseDirectUserAsset + ? pickPreferredAssetIdForShot(shot, assets, usageByAsset, repetitionGuard, durationSec, topic) + : undefined)); + const matchedAssetId = slot + ? lockedAssetId ?? (match?.status === 'matched' + ? (preferredAssetId ?? (shouldUseCoverageFallback ? undefined : match.assetId)) + : undefined) + : preferredAssetId; + const mixedReferenceBridge = !matchedAssetId + ? mixedReferenceBridgeForShot(shot, visualGapMode, visualReferenceAssets) + : undefined; + if (mixedReferenceBridge) { + const bridgeEndSec = mixedReferenceBridgeEndSec(shotSpan, visualGapPolicy.maxReferenceClipSec); + const referenceBridgeFill = bridgeEndSec == null + ? undefined + : createFillForStrategy({ + strategy: 'reference_clip', + slotId: `${slot?.id ?? shot.shotId}_bridge`, + assets, + referenceAssets: visualReferenceAssets, + usageByAsset, + topic, + sellingPoints, + startSec: shotSpan.startSec, + endSec: bridgeEndSec, + index, + visualGapMode, + requiredVisualFunctions: mixedReferenceBridge.bridgeFunctions, + }); + if (bridgeEndSec != null && referenceBridgeFill) { + fills.push(referenceBridgeFill); + visualIndex += 1; + const bridgeSource = { kind: 'fill_artifact', fillArtifactId: referenceBridgeFill.id } as TimelineSource; + const referenceSourceAsset = referenceAssetFromFill(referenceBridgeFill, visualReferenceAssets); + const bridgeSourceWindow = sourceWindowForReferenceAsset( + referenceSourceAsset, + shotSpan.startSec, + bridgeEndSec, + durationSec, + sourceWindowCursor, + ); + items.push({ + id: `it_${index + 1}_${shot.shotId}_mixed_ref_bridge`, + track: referenceBridgeFill.track, + startSec: shotSpan.startSec, + endSec: bridgeEndSec, + sourceInSec: bridgeSourceWindow?.sourceInSec, + sourceOutSec: bridgeSourceWindow?.sourceOutSec, + motionPreset: 'static', + transitionPreset: shot.transitionPreset, + cropPreset: 'contain', + framePolicy: 'reference_viewport', + slotRef: slot?.id, + shotRef: shot.shotId, + rhythmAnchor: nearestBeatAnchor(shotSpan.startSec, beatGrid, rhythmPlan), + source: bridgeSource, + }); + + const remainderFill = createSegmentFill({ + segmentRole: shot.segmentRole, + slotId: `${slot?.id ?? shot.shotId}_mixed_remainder`, + topic, + sellingPoints, + startSec: bridgeEndSec, + endSec: shotSpan.endSec, + index: index + 1000, + assets, + referenceAssets: [], + usageByAsset, + preferredStrategies: ['reused_clip', 'packaging_overlay', 'copy_completion'], + visualGapMode: 'smart_fill', + requiredVisualFunctions: mixedReferenceBridge.remainderFunctions, + }); + fills.push(remainderFill); + const remainderSource = { kind: 'fill_artifact', fillArtifactId: remainderFill.id } as TimelineSource; + const remainderSourceAsset = assetFromFill(remainderFill, assets); + const remainderIsCard = isGeneratedCardSource(remainderSource, fills); + const remainderSourceWindow = sourceWindowFor( + remainderSourceAsset, + bridgeEndSec, + shotSpan.endSec, + sourceWindowCursor, + ); + const remainderFramePolicy = framePolicyForShot({ + sourceAsset: remainderSourceAsset, + shot, + fill: remainderFill, + generatedCard: remainderIsCard, + }); + items.push({ + id: `it_${index + 1}_${shot.shotId}_mixed_remainder`, + track: remainderFill.track, + startSec: bridgeEndSec, + endSec: shotSpan.endSec, + sourceInSec: remainderSourceWindow?.sourceInSec, + sourceOutSec: remainderSourceWindow?.sourceOutSec, + motionPreset: remainderIsCard ? 'ken_burns_in' : stableMotionPresetFor({ + track: remainderFill.track, + asset: remainderSourceAsset, + isGeneratedCard: false, + requested: shot.motionPreset, + segmentRole: shot.segmentRole, + itemDurationSec: shotSpan.endSec - bridgeEndSec, + previousImageMotion, + visualIndex, + lowMaterialExpansion: lowMaterialPlan, + }), + transitionPreset: remainderIsCard ? transitionPresetForCard(cardTreatment.animation) : shot.transitionPreset, + cropPreset: cropPresetForFramePolicy(remainderFramePolicy, shot.cropPreset), + framePolicy: remainderFramePolicy, + cardStylePreset: remainderIsCard ? cardTreatment.style : undefined, + cardAnimationPreset: remainderIsCard ? cardTreatment.animation : undefined, + slotRef: slot?.id, + shotRef: shot.shotId, + rhythmAnchor: nearestBeatAnchor(bridgeEndSec, beatGrid, rhythmPlan), + source: remainderSource, + }); + markCardTextSeen(remainderSource, fills, earlyCardTexts, bridgeEndSec); + recordTimelineAssetUse(remainderSource, remainderSourceAsset, usageByAsset); + return; + } + } + if (matchedAssetId) { + sourceAsset = assets.find((a) => a.id === matchedAssetId); + source = { kind: 'user_asset', assetId: matchedAssetId }; + track = sourceAsset?.mediaType === 'text' ? 'text' : 'video'; + } else { + const fallbackFill = + fill ?? + createSegmentFill({ + segmentRole: shot.segmentRole, + slotId: slot?.id ?? shot.shotId, + topic, + sellingPoints, + startSec: shotSpan.startSec, + endSec: shotSpan.endSec, + index, + assets, + referenceAssets: visualReferenceAssets, + usageByAsset, + preferredStrategies: shouldUseCoverageFallback + ? coverageAwareFallbackStrategies(shot.fallbackStrategies, visualGapMode, shot.visualFunctions ?? []) + : shot.fallbackStrategies, + visualGapMode, + requiredVisualFunctions: shot.visualFunctions ?? [], + }); + if (!fill) fills.push(fallbackFill); + source = { kind: 'fill_artifact', fillArtifactId: fallbackFill.id }; + track = fallbackFill.track; + sourceAsset = assetFromFill(fallbackFill, assets) ?? referenceAssetFromFill(fallbackFill, visualReferenceAssets); } - const fallbackFill = - fill ?? - createSegmentFill({ - segmentRole: segment.role, - slotId: slot?.id ?? `segment_${segment.role}`, + const isGeneratedCard = isGeneratedCardSource(source, fills); + const slice = { startSec: shotSpan.startSec, endSec: shotSpan.endSec }; + visualIndex += 1; + let guarded = applyAssetRepetitionGuard({ + state: repetitionGuard, + source, + track, + sourceAsset, + slice, + slotId: slot?.id ?? shot.shotId, + topic, + sellingPoints, + assets, + usageByAsset, + durationSec, + index: visualIndex, + storyFunction: shot.storyFunction, + lowMaterialExpansion: lowMaterialPlan, + }); + if (guarded.fill) fills.push(guarded.fill); + let guardedIsGeneratedCard = isGeneratedCard || isGeneratedCardSource(guarded.source, fills); + if (guardedIsGeneratedCard && isDuplicateEarlyCard(guarded.source, fills, earlyCardTexts, shotSpan.startSec)) { + const alternate = pickGuardAlternateAsset({ + assets, + state: repetitionGuard, + originalKey: fallbackSourceKey(guarded.source), + usageByAsset, + duration: Math.max(0, shotSpan.endSec - shotSpan.startSec), + durationSec, + storyFunction: shot.storyFunction, + }); + if (alternate) { + guarded = { + source: { kind: 'user_asset', assetId: alternate.id }, + track: alternate.mediaType === 'text' ? 'text' : 'video', + sourceAsset: alternate, + }; + recordGuardSource( + repetitionGuard, + `asset:${alternate.id}`, + visualClusterKey(alternate), + visualFunctionRepetitionKeys(alternate), + Math.max(0, shotSpan.endSec - shotSpan.startSec), + shot.storyFunction, + ); + incrementUse(usageByAsset, alternate.id); + guardedIsGeneratedCard = false; + } else { + markCardTextSeen(guarded.source, fills, earlyCardTexts, shotSpan.startSec); + } + } else { + markCardTextSeen(guarded.source, fills, earlyCardTexts, shotSpan.startSec); + } + const referenceBridgeFill = fillForTimelineSource(guarded.source, fills); + if ( + visualGapPolicy.referenceClipUse === 'bridge_only' && + referenceBridgeFill?.kind === 'reference_clip' && + shotSpan.endSec - shotSpan.startSec > visualGapPolicy.maxReferenceClipSec + ) { + const bridgeEndSec = round(Math.min(shotSpan.endSec, shotSpan.startSec + visualGapPolicy.maxReferenceClipSec)); + const referenceSourceAsset = referenceAssetFromFill(referenceBridgeFill, visualReferenceAssets); + const bridgeSourceWindow = sourceWindowForReferenceAsset( + referenceSourceAsset, + shotSpan.startSec, + bridgeEndSec, + durationSec, + sourceWindowCursor, + ); + items.push({ + id: `it_${index + 1}_${shot.shotId}_ref_bridge`, + track: guarded.track, + startSec: shotSpan.startSec, + endSec: bridgeEndSec, + sourceInSec: bridgeSourceWindow?.sourceInSec, + sourceOutSec: bridgeSourceWindow?.sourceOutSec, + motionPreset: 'static', + transitionPreset: shot.transitionPreset, + cropPreset: 'contain', + framePolicy: 'reference_viewport', + slotRef: slot?.id, + shotRef: shot.shotId, + rhythmAnchor: nearestBeatAnchor(shotSpan.startSec, beatGrid, rhythmPlan), + source: guarded.source, + }); + const remainderFill = createSegmentFill({ + segmentRole: shot.segmentRole, + slotId: `${slot?.id ?? shot.shotId}_bridge_remainder`, topic, sellingPoints, - startSec, - endSec, - index, + startSec: bridgeEndSec, + endSec: shotSpan.endSec, + index: index + 1000, + assets, + referenceAssets: [], + usageByAsset, + preferredStrategies: ['reused_clip', 'packaging_overlay', 'copy_completion'], + visualGapMode: 'smart_fill', + requiredVisualFunctions: [], + }); + fills.push(remainderFill); + const remainderSource = { kind: 'fill_artifact', fillArtifactId: remainderFill.id } as TimelineSource; + const remainderIsCard = isGeneratedCardSource(remainderSource, fills); + const remainderSourceAsset = assetFromFill(remainderFill, assets); + const remainderFramePolicy = framePolicyForShot({ + sourceAsset: remainderSourceAsset, + shot, + fill: remainderFill, + generatedCard: remainderIsCard, + }); + items.push({ + id: `it_${index + 1}_${shot.shotId}_bridge_remainder`, + track: remainderFill.track, + startSec: bridgeEndSec, + endSec: shotSpan.endSec, + motionPreset: remainderIsCard ? 'ken_burns_in' : shot.motionPreset, + transitionPreset: remainderIsCard ? transitionPresetForCard(cardTreatment.animation) : shot.transitionPreset, + cropPreset: cropPresetForFramePolicy(remainderFramePolicy, shot.cropPreset), + framePolicy: remainderFramePolicy, + cardStylePreset: remainderIsCard ? cardTreatment.style : undefined, + cardAnimationPreset: remainderIsCard ? cardTreatment.animation : undefined, + slotRef: slot?.id, + shotRef: shot.shotId, + rhythmAnchor: nearestBeatAnchor(bridgeEndSec, beatGrid, rhythmPlan), + source: remainderSource, }); - if (!fill) fills.push(fallbackFill); + recordTimelineAssetUse(guarded.source, guarded.sourceAsset, usageByAsset); + return; + } + const sourceWindow = sourceWindowFor(guarded.sourceAsset, shotSpan.startSec, shotSpan.endSec, sourceWindowCursor); + const motionPreset = templateAwareMotionPreset(stableMotionPresetFor({ + track: guarded.track, + asset: guarded.sourceAsset, + isGeneratedCard: guardedIsGeneratedCard, + requested: shot.motionPreset, + segmentRole: shot.segmentRole, + itemDurationSec: shotSpan.endSec - shotSpan.startSec, + previousImageMotion, + visualIndex, + lowMaterialExpansion: lowMaterialPlan, + }), templateProfile, guarded.track, guarded.sourceAsset, guardedIsGeneratedCard, visualIndex); + if (guarded.sourceAsset?.mediaType === 'image' && motionPreset !== 'static') { + previousImageMotion = motionPreset; + } + const transitionPreset = templateAwareTransitionPreset(stableTransitionPresetFor({ + track: guarded.track, + asset: guarded.sourceAsset, + isGeneratedCard: guardedIsGeneratedCard, + requested: guardedIsGeneratedCard + ? transitionPresetForCard(cardTreatment.animation) + : shot.transitionPreset, + }), templateProfile, guarded.track, guardedIsGeneratedCard); + const cropPreset = referenceBridgeFill?.kind === 'reference_clip' + ? 'contain' + : (lowMaterialPlan.enabled ? undefined : guarded.sourceAsset?.safeCropPreset) + ?? lowMaterialCropPresetFor(shot.cropPreset, guarded.sourceAsset, visualIndex, lowMaterialPlan); + const framePolicy = framePolicyForShot({ + sourceAsset: guarded.sourceAsset, + shot, + fill: referenceBridgeFill, + generatedCard: guardedIsGeneratedCard, + }); items.push({ - id: `it_${index + 1}_${segment.role}_fill`, - track: fallbackFill.track, - startSec, - endSec, + id: `it_${index + 1}_${shot.shotId}`, + track: guarded.track, + startSec: shotSpan.startSec, + endSec: shotSpan.endSec, + sourceInSec: sourceWindow?.sourceInSec, + sourceOutSec: sourceWindow?.sourceOutSec, + motionPreset, + transitionPreset, + cropPreset: cropPresetForFramePolicy(framePolicy, cropPreset), + framePolicy, + cardStylePreset: guardedIsGeneratedCard ? cardTreatment.style : undefined, + cardAnimationPreset: guardedIsGeneratedCard ? cardTreatment.animation : undefined, slotRef: slot?.id, - source: { kind: 'fill_artifact', fillArtifactId: fallbackFill.id }, + shotRef: shot.shotId, + rhythmAnchor: nearestBeatAnchor(shotSpan.startSec, beatGrid, rhythmPlan), + source: guarded.source, }); + recordTimelineAssetUse(guarded.source, guarded.sourceAsset, usageByAsset); + }); + + const autoReuseUploadedBgm = input.reuseUploadedBgm !== false; + const bgm = selectBgmSource(assets, referenceAssets, templateProfile, { + allowAutoReuse: autoReuseUploadedBgm, + sampleAnalysis: input.sampleAnalysis, }); + if (bgm) { + items.push({ + id: 'it_user_audio', + track: 'audio', + startSec: 0, + endSec: durationSec, + source: bgm.source, + }); + } + const referenceClipGuardRevision = applyReferenceClipBudgetGuard({ + items, + fills, + assets, + referenceAssets: visualReferenceAssets, + visualGapPolicy, + durationSec, + topic, + sellingPoints, + }); const timeline = Timeline.parse({ id: `tl_${randomUUID().slice(0, 8)}`, projectId: input.projectId, durationSec, - items, + beatGrid, + rhythmPlan, + templateProfile, + items: referenceClipGuardRevision.items, }); + const pacingPhaseSummary = pacingEnvelope.phases + .map((phase) => `${phase.role}:${phase.avgShotSec}s/${phase.cutDensity}`) + .join(' / '); const evidence: Evidence[] = [ { type: 'asset_tag', - detail: `迁移使用 ${input.assets.length} 个用户素材`, - ref: input.assets.map((a) => a.id).join(', '), + detail: `迁移使用 ${assets.length} 个用户素材;旧素材会按 summary/tag 启发式补齐 storyRoles/narrativeUse/visualMood/visualClusterId`, + ref: assets.map((a) => a.id).join(', '), + }, + { + type: 'migration_controls', + detail: `迁移意图=${migrationIntent};素材不足策略=${visualGapMode}/${visualGapPolicy.adaptationMode};参考画面策略=${referenceClipMode}${hasReferenceAsset && !allowReferenceClips ? '(已检测到参考视频,但不会作为画面进入 timeline)' : ''};模板适配=${templateAdaptationMode};锁定 pattern=${migrationControls.locks.lockedPatternIds.join('/') || 'none'};指定素材=${lockedAssetApplications.length}`, + }, + ...(lockedAssetApplications.length + ? [ + { + type: 'migration_locks', + detail: `已应用 ${lockedAssetApplications.length} 条 shot/slot -> asset 指定:${lockedAssetApplications.slice(0, 8).join(', ')}`, + ref: lockedAssetApplications.join(', '), + }, + ] + : []), + ...(hasExternalMigrationSignals(migrationControls) + ? [ + { + type: 'analysis_signals', + detail: `外部分析信号已进入迁移上下文:ASR cues=${migrationControls.signals.transcriptCues.length},music sections=${migrationControls.signals.musicSections.length},clip scores=${migrationControls.signals.clipScores.length},safe crop hints=${migrationControls.signals.safeCropHints.length}`, + }, + ] + : []), + { + type: 'visual_gap_policy', + detail: visualGapPolicy.rationale, + }, + ...(excludedVisualReferenceAssets.length + ? [ + { + type: 'reference_relevance_filter', + detail: `已过滤 ${excludedVisualReferenceAssets.length} 个与当前主题不相关或带人物 / 口播风险的参考画面候选,避免全局样例库的教程、转场、自拍或真人出镜素材进入成片画面;保留 ${visualReferenceAssets.length} 个候选。`, + ref: excludedVisualReferenceAssets.slice(0, 8).map((asset) => asset.id).join(', '), + }, + ] + : []), + ...referenceClipGuardRevision.evidence, + ...(visualGapPolicy.adaptationMode === 'needs_capture' + ? [ + { + type: 'story_budget_gap', + detail: `关键故事职责缺少可验证素材,需要补拍或补充素材;missing=${visualCoverage.missingFunctions.join('/') || 'none'},weak=${visualCoverage.weakFunctions.join('/') || 'none'}。`, + ref: visualCoverage.missingFunctions.join(', ') || undefined, + }, + ] + : []), + ...(lowMaterialPlan.enabled + ? [ + { + type: 'low_material_expansion', + detail: lowMaterialPlan.reason, + }, + ] + : []), + { + type: 'visual_coverage', + detail: `${visualCoverage.recommendation} required=${visualCoverage.requiredFunctions.join('/')} missing=${visualCoverage.missingFunctions.join('/')} weak=${visualCoverage.weakFunctions.join('/')} closeUpLikeShare=${visualCoverage.closeUpLikeShare}`, + ref: visualCoverage.missingFunctions.join(', ') || undefined, }, + ...(aspectGuard.aspectMismatch + ? [ + { + type: 'aspect_mismatch_guard', + detail: aspectGuard.reason, + ref: rawTemplateProfile?.id, + }, + ] + : []), { type: 'slot_match', detail: `匹配 ${matches.filter((m) => m.status === 'matched').length}/${matches.length} 个结构槽位`, }, { type: 'gap', - detail: `检测到 ${gaps.length} 个素材缺口,已用 text_card 兜底生成 FillArtifact`, + detail: `检测到 ${gaps.length} 个素材缺口,按结构/文案/包装/AIGC 优先策略生成 ${fills.length} 个 FillArtifact(copy_completion=${fills.filter((f) => f.kind === 'copy_completion').length}, packaging_overlay=${fills.filter((f) => f.kind === 'packaging_overlay').length}, aigc=${fills.filter((f) => f.kind === 'aigc_clip' || f.kind === 'aigc_image').length}, reused_clip=${fills.filter((f) => f.kind === 'reused_clip').length}, reference_clip=${fills.filter((f) => f.kind === 'reference_clip').length})`, + }, + { + type: 'asset_repetition_guard', + detail: repetitionGuard.replacements.length + ? `素材重复护栏触发 ${repetitionGuard.replacements.length} 次,其中 ${repetitionGuard.guardFillCount} 次用包装卡片补位,避免同一真实素材长时间连续播放` + : `素材重复护栏未触发;同一真实素材连续 / 总占比未超过阈值`, + ref: repetitionGuard.replacements.map((r) => `${r.originalKey}->${r.replacementKey}@${r.startSec}-${r.endSec}`).join(', '), + }, + { + type: 'duration', + detail: input.durationSec + ? `使用用户指定目标时长 ${durationSec}s` + : `质量优先自适应目标时长 ${durationSec}s,避免素材不足时硬拉到 30s`, + }, + { + type: 'bgm', + detail: bgm + ? `已使用${bgm.sourceKindLabel}作为 BGM:${bgm.label}` + : autoReuseUploadedBgm + ? '未找到可自动复用的上传音频、有声视频或样例音频,timeline 不添加 BGM 轨。' + : '未指定 BGM,且用户关闭了自动复用音频 / 视频原声 / 样例音频,timeline 不添加 BGM 轨。', + ref: bgm?.ref, + }, + { + type: 'bgm', + detail: `BGM 卡点网格:${beatGrid.bpm} BPM,${beatGrid.beatsSec.length} 个 beat;视觉 shot 边界已吸附到最近 beat(confidence=${beatGrid.confidence})`, + ref: beatGrid.source, + }, + { + type: 'rhythm_transfer', + detail: rhythmPlan.matchedGlobalPattern + ? `目标 BGM 命中全局相似节奏样例「${rhythmPlan.matchedGlobalPattern.name}」(score=${rhythmPlan.matchedGlobalPattern.score}),使用其 rhythmProfile 做 beat-index 映射;strategy=${rhythmPlan.strategy}` + : `未命中足够相似的全局 BGM 样例,使用用户样例节奏结构映射到目标 beat grid;strategy=${rhythmPlan.strategy}`, + ref: rhythmPlan.matchedGlobalPattern?.patternId ?? input.sampleId, + }, + { + type: 'pacing_envelope', + detail: `PacingEnvelopeBlend:当前样例权重 ${pacingEnvelope.sampleWeight},全局样例权重 ${pacingEnvelope.globalWeight};阶段 ${pacingPhaseSummary}`, + ref: globalPacingPattern?.id ?? input.sampleId, + }, + ...(templateProfile + ? [ + { + type: 'template_profile', + detail: `套用样例模板 ${templateProfile.layoutPreset}:${templateProfile.strategySummary}`, + ref: templateProfile.id, + }, + ] + : []), + { + type: 'director_plan', + detail: `DirectorPlan 先行生成 ${directorPlan.shots.length} 个 directed shot;素材重复风险 ${directorPlan.assetBudget.repeatedVisualRisk}`, + ref: directorPlan.id, }, + ...(input.directorPlan + ? [ + { + type: 'director_plan_source', + detail: 'Migration 使用外部传入的 DirectorPlan 作为 shot / storyArc / editConstraints 主控,没有重新生成启发式导演计划。', + ref: input.directorPlan.id, + }, + ] + : []), + ...(selectedPattern + ? [ + { + type: 'sample_pattern', + detail: `迁移参考样例库 pattern「${selectedPattern.reusablePatternName}」:${selectedPattern.formula}`, + ref: selectedPattern.id, + }, + { + type: 'card_style', + detail: `文字 / 包装卡片采用样例 pattern 推导样式 ${cardTreatment.style} + 动画 ${cardTreatment.animation}`, + ref: selectedPattern.id, + }, + ] + : []), ]; - const decisions: Decision[] = gaps.map((gap) => ({ - chosen: 'text_card', - alternatives: ['packaging_overlay', 'reused_clip', 'aigc_image'], - confidence: 0.82, - reason: `${gap.slotId} 暂无可靠匹配素材,规则版 P0 先用文字卡保证时间线不断裂。`, - })); + const patternDecision: Decision[] = selectedPattern + ? [ + { + chosen: selectedPattern.id, + alternatives: rankedPatterns.slice(1, 4).map((p) => p.id), + confidence: 0.72, + reason: `按体裁、节奏标签和槽位需求选择样例库 pattern「${selectedPattern.reusablePatternName}」,用于约束镜头密度和专家迁移提示。`, + }, + ] + : []; + + const decisions: Decision[] = [ + ...patternDecision, + ...referenceClipGuardRevision.decisions, + { + chosen: `${migrationIntent}/${visualGapMode}/${templateAdaptationMode}`, + alternatives: [ + 'story_only/user_only/portrait_safe', + 'editing_only/smart_fill/auto', + 'story_and_editing/reference_bridge/preserve_sample_frame', + ], + confidence: 0.8, + reason: `先按用户选择把故事叙述、剪辑手法、素材不足补全和画幅模板分开授权,避免全局样例或优质案例整包串味。${visualGapPolicy.rationale}`, + }, + { + chosen: rhythmPlan.strategy, + alternatives: ['same_bgm_direct', 'beat_index_mapping', 'global_music_match', 'target_music_regenerate'], + confidence: rhythmPlan.matchedGlobalPattern ? rhythmPlan.matchedGlobalPattern.score : beatGrid.confidence === 'high' ? 0.78 : 0.66, + reason: rhythmPlan.matchedGlobalPattern + ? `目标 BGM 与全局样例「${rhythmPlan.matchedGlobalPattern.name}」相似,采用该样例的剪辑密度和事件节奏;当前样例仍负责内容结构。` + : `没有足够相似的全局 BGM 样例时,保留当前样例的节奏结构,并把切镜 / 字幕事件吸附到目标 beat grid。`, + }, + { + chosen: 'pacing_envelope_blend', + alternatives: ['current_sample_only', 'global_only', 'flat_even_cut'], + confidence: globalPacingPattern ? 0.74 : 0.62, + reason: globalPacingPattern + ? `素材变化速度采用当前样例 ${pacingEnvelope.sampleWeight} + 全局样例 ${pacingEnvelope.globalWeight} 的加权 envelope;当前样例保持主导,全局节奏提供爆款剪辑的快慢参考,QC 可在后续迭代中小幅调权。` + : `未找到可用全局样例时,素材变化速度先按当前样例 pacing envelope 执行,QC 可继续收集结果用于后续调权。`, + }, + ...(templateProfile + ? [ + { + chosen: templateProfile.layoutPreset, + alternatives: ['full_bleed', 'cinematic_matte', 'letterbox_frame'], + confidence: templateProfile.source === 'user_sample' ? 0.82 : 0.68, + reason: `样例学习识别到可执行模板 profile,迁移时由 Remotion 保留画幅框、内部运动和遮罩/onset 锚点。`, + }, + ] + : []), + ...(aspectGuard.aspectMismatch && !templateProfile + ? [ + { + chosen: 'portrait_safe_template_guard', + alternatives: ['preserve_sample_frame', 'force_cinematic_viewport'], + confidence: 0.82, + reason: aspectGuard.reason, + }, + ] + : []), + { + chosen: `${durationSec}s`, + alternatives: input.durationSec ? [] : ['30s_fixed', 'sample_duration', 'asset_budget'], + confidence: input.durationSec ? 0.96 : 0.78, + reason: input.durationSec + ? '用户显式指定目标时长,迁移按该时长分配段落。' + : '未指定目标时长时,按段落数、样例节奏 pattern 和项目素材可用时长估算,优先保证信息密度与观感质量。', + }, + ...(selectedPattern + ? [ + { + chosen: `${cardTreatment.style}/${cardTreatment.animation}`, + alternatives: ['minimal_dark/fade_push', 'title_bar/slide_left', 'sticker_pop/snap_pop'], + confidence: 0.7, + reason: `根据样例库 pattern 的包装字段(标题条、贴纸、转场描述、节奏密度)推导文字卡出现方式。`, + }, + ] + : []), + ...(migrationControls.locks.lockedPatternIds.length || lockedAssetApplications.length + ? [ + { + chosen: 'user_locked_migration_controls', + alternatives: ['fully_automatic_pattern_and_asset_selection'], + confidence: 0.9, + reason: `本次尊重用户控制项:锁定 pattern=${migrationControls.locks.lockedPatternIds.join('/') || 'none'};实际应用素材指定 ${lockedAssetApplications.length} 条。`, + }, + ] + : []), + ...(repetitionGuard.replacements.length + ? [ + { + chosen: 'asset_repetition_guard', + alternatives: ['keep_reusing_asset', 'shorten_video', 'insert_static_gap'], + confidence: 0.76, + reason: `检测到 ${repetitionGuard.replacements.length} 个镜头会造成真实素材连续或总占比过高,已优先切换其他素材,无法切换时插入样例 pattern 风格包装卡片。`, + }, + ] + : []), + ...gaps.map((gap) => { + const fill = fills.find((f) => f.slotId === gap.slotId); + return { + chosen: fill?.kind ?? 'copy_completion', + alternatives: ['structure_reframe', 'copy_completion', 'packaging_overlay', 'aigc_image', 'aigc_clip', 'reused_clip', 'reference_clip'], + confidence: fill?.kind === 'reused_clip' || fill?.kind === 'reference_clip' ? 0.62 : 0.82, + reason: + fill?.kind === 'reused_clip' || fill?.kind === 'reference_clip' + ? `${gap.slotId} 在结构/文案/包装/AIGC 补全不足以流畅承接时,才低优先级复用现有或参考素材。` + : `${gap.slotId} 暂无精准素材匹配,优先通过结构重排、文案补全、包装补全或 AIGC 占位保证时间线不断裂。`, + }; + }), + ]; + let finalTimeline = timeline; + let finalFills = fills; + let finalScript = script; + let finalStoryboard = storyboard; + let finalEvidence = evidence; + let finalDecisions = decisions; + const textCardBudgetRevision = applyFullScreenTextCardBudgetRevision({ + timeline: finalTimeline, + fills: finalFills, + script: finalScript, + storyboard: finalStoryboard, + directorPlan, + assets, + durationSec, + }); + if (textCardBudgetRevision.changed) { + finalTimeline = textCardBudgetRevision.timeline; + finalScript = textCardBudgetRevision.script; + finalStoryboard = textCardBudgetRevision.storyboard; + finalEvidence = [...finalEvidence, ...textCardBudgetRevision.evidence]; + finalDecisions = [...finalDecisions, ...textCardBudgetRevision.decisions]; + } + let directorArtifacts = buildDirectorArtifacts({ + blueprint: input.blueprint, + assets, + topic, + sellingPoints, + durationSec, + directorPlan, + matches, + gaps, + fills: finalFills, + script: finalScript, + storyboard: finalStoryboard, + timeline: finalTimeline, + evidence: finalEvidence, + }); + const qcAutoRevision = applyQcAutoRevision({ + timeline: finalTimeline, + fills: finalFills, + assets, + qcReport: directorArtifacts.qcReport, + }); + if (qcAutoRevision.changed) { + finalTimeline = qcAutoRevision.timeline; + finalFills = qcAutoRevision.fills; + finalEvidence = [...finalEvidence, ...qcAutoRevision.evidence]; + finalDecisions = [...finalDecisions, ...qcAutoRevision.decisions]; + const postQcTextCardBudgetRevision = applyFullScreenTextCardBudgetRevision({ + timeline: finalTimeline, + fills: finalFills, + script: finalScript, + storyboard: finalStoryboard, + directorPlan, + assets, + durationSec, + }); + if (postQcTextCardBudgetRevision.changed) { + finalTimeline = postQcTextCardBudgetRevision.timeline; + finalScript = postQcTextCardBudgetRevision.script; + finalStoryboard = postQcTextCardBudgetRevision.storyboard; + finalEvidence = [...finalEvidence, ...postQcTextCardBudgetRevision.evidence]; + finalDecisions = [...finalDecisions, ...postQcTextCardBudgetRevision.decisions]; + } + directorArtifacts = buildDirectorArtifacts({ + blueprint: input.blueprint, + assets, + topic, + sellingPoints, + durationSec, + directorPlan, + matches, + gaps, + fills: finalFills, + script: finalScript, + storyboard: finalStoryboard, + timeline: finalTimeline, + evidence: finalEvidence, + }); + } return MigrationPlan.parse({ id: `mig_${randomUUID().slice(0, 8)}`, @@ -198,71 +1279,3404 @@ export function runRuleBasedMigration(input: RuleBasedMigrationInput): Migration sellingPoints, matches, gaps, - fills, - script, - storyboard, - timeline, - evidence, - decisions, - rationale: - '规则版迁移:按蓝图段落比例生成脚本和时间线,用标签重合度匹配素材;未匹配槽位生成 text_card FillArtifact,保证 P0 闭环可渲染。', + fills: finalFills, + script: finalScript, + storyboard: finalStoryboard, + timeline: finalTimeline, + visualCoverage, + visualGapPolicy, + directorPlan, + ...directorArtifacts, + evidence: finalEvidence, + decisions: finalDecisions, + rationale: input.sellingPoints?.length + ? '规则版迁移:先由 DirectorPlan 根据用户目标、样例结构和素材预算设计 directed shots,再按 shotRef 生成脚本、分镜和时间线;槽位匹配、缺口补全和素材重复护栏共同保证导演计划可渲染。' + : '规则版迁移:用户没有提供明确卖点或创意 brief 时,默认迁移样例故事弧线和段落功能,再结合用户素材生成类似的脚本、分镜和时间线。', }); } -function matchSlot(slot: StructureSlot, assets: TaggedAsset[]): SlotMatch { - const ranked = assets - .map((asset) => { - const overlap = asset.assetTags.filter((tag) => slot.requiredAssetTypes.includes(tag)).length; - const tagScore = overlap / slot.requiredAssetTypes.length; - const durationOk = !slot.minDurationSec || (asset.durationSec ?? 0) >= slot.minDurationSec; - const confidenceOk = asset.confidence >= MIN_CONFIDENCE; - // 时长不足只降权、不淘汰:迁移到更短目标时,单条素材无需填满整段(可裁切 / 复用)。 - const durationPenalty = durationOk ? 1 : 0.6; - const score = - confidenceOk && overlap > 0 ? round(Math.min(1, tagScore * asset.confidence * durationPenalty)) : 0; - return { asset, overlap, durationOk, confidenceOk, score }; - }) - .sort((a, b) => b.score - a.score || b.overlap - a.overlap); +function chooseTemplateProfile( + currentSampleProfile: TemplateProfile | undefined, + selectedPatternProfile: TemplateProfile | undefined, +): TemplateProfile | undefined { + if (isActionableTemplateProfile(currentSampleProfile)) { + return { ...currentSampleProfile, source: 'user_sample' }; + } + if (isActionableTemplateProfile(selectedPatternProfile)) { + return { ...selectedPatternProfile, source: 'global_sample' }; + } + return undefined; +} + +function selectLearnedPatternForControls( + rankedPatterns: LearnedSamplePattern[], + controls: MigrationControlsT, +): LearnedSamplePattern | undefined { + const lockedIds = controls.locks.lockedPatternIds; + if (!lockedIds.length) return rankedPatterns[0]; + const byId = new Map(rankedPatterns.map((pattern) => [pattern.id, pattern])); + return lockedIds.map((id) => byId.get(id)).find((pattern): pattern is LearnedSamplePattern => Boolean(pattern)) ?? rankedPatterns[0]; +} + +function lockedAssetIdForShot( + shot: DirectorPlanShot, + slot: StructureSlot | undefined, + assets: TaggedAsset[], + controls: MigrationControlsT, +): string | undefined { + const assignments = controls.locks.shotAssetAssignments; + const assignedId = [ + shot.shotId, + slot?.id, + `${shot.segmentRole}`, + shot.storyFunction, + ] + .map((key) => key ? assignments[key] : undefined) + .find((assetId): assetId is string => Boolean(assetId)); + if (!assignedId) return undefined; + return reusableAssets(assets).some((asset) => asset.id === assignedId) ? assignedId : undefined; +} + +function applyMigrationSignalsToAsset(asset: TaggedAsset, controls: MigrationControlsT): TaggedAsset { + const clipScore = controls.signals.clipScores.find((score) => score.assetId === asset.id); + const safeCrop = controls.signals.safeCropHints.find((hint) => hint.assetId === asset.id); + if (!clipScore && !safeCrop) return asset; + return { + ...asset, + qualityScore: clipScore?.score ?? asset.qualityScore, + highlightWindows: clipScore?.highlightWindows.length ? clipScore.highlightWindows : asset.highlightWindows, + safeCropPreset: safeCrop?.preset ?? asset.safeCropPreset, + }; +} + +function hasExternalMigrationSignals(controls: MigrationControlsT): boolean { + return ( + controls.signals.transcriptCues.length > 0 || + controls.signals.musicSections.length > 0 || + controls.signals.clipScores.length > 0 || + controls.signals.safeCropHints.length > 0 + ); +} - const best = ranked[0]; - if (best && best.score > 0) { - const shortNote = best.durationOk ? '' : '(时长偏短,渲染时裁切 / 复用)'; +function assessTemplateAspectGuard( + templateProfile: TemplateProfile | undefined, + assets: TaggedAsset[], + mode: z.infer, +): { useTemplate: boolean; aspectMismatch: boolean; reason: string } { + if (!templateProfile) return { useTemplate: false, aspectMismatch: false, reason: 'no_template' }; + if (mode === 'preserve_sample_frame') { + return { useTemplate: true, aspectMismatch: false, reason: '用户选择保留样例画幅模板。' }; + } + const templateAspect = aspectRatioNumber(templateProfile.sourceAspect); + const visualAssets = reusableAssets(assets); + const knownAspects = visualAssets.map((asset) => aspectRatioNumber(asset.aspectRatio)).filter((n) => n > 0); + const portraitShare = knownAspects.length + ? knownAspects.filter((aspect) => aspect < 0.9).length / knownAspects.length + : 0; + const aspectMismatch = templateAspect > 1.2 && portraitShare >= 0.6; + if (aspectMismatch && (mode === 'auto' || mode === 'portrait_safe')) { return { - slotId: slot.id, - assetId: best.asset.id, - score: best.score, - status: 'matched', - reason: `命中标签 ${best.asset.assetTags.join('/')},置信度 ${best.asset.confidence}${shortNote}`, + useTemplate: false, + aspectMismatch, + reason: `样例画幅为横屏(${templateProfile.sourceAspect}),用户素材多数为竖屏;已保留节奏 / 故事迁移,但降级为 portrait-safe layout,避免横向 cover 裁成局部特写。`, }; } + return { useTemplate: true, aspectMismatch, reason: '样例模板与素材画幅兼容。' }; +} + +function aspectRatioNumber(aspect: string | undefined): number { + if (!aspect) return 0; + const [w, h] = aspect.split(':').map(Number); + return Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : 0; +} + +function cropPresetForFramePolicy( + framePolicy: FramePolicy | undefined, + fallback: CropPreset | undefined, +): CropPreset | undefined { + if (framePolicy === 'landscape_viewport' || framePolicy === 'reference_viewport' || framePolicy === 'blurred_pad') { + return 'contain'; + } + return fallback; +} + +function framePolicyForShot(opts: { + sourceAsset?: TaggedAsset; + shot?: DirectorPlanShot; + fill?: FillArtifact; + generatedCard?: boolean; +}): FramePolicy | undefined { + if (opts.generatedCard) return undefined; + if (opts.fill?.kind === 'reference_clip') return 'reference_viewport'; + const asset = opts.sourceAsset; + if (!asset || (asset.mediaType !== 'video' && asset.mediaType !== 'image')) return undefined; - const hasTagOverlap = ranked.some((r) => r.overlap > 0); - const reason = hasTagOverlap - ? `有标签相近素材,但时长或置信度不足(minDurationSec=${slot.minDurationSec ?? 'n/a'})` - : `没有素材命中 ${slot.requiredAssetTypes.join('/')}`; - return { slotId: slot.id, score: 0, status: 'gap', reason }; + const aspect = aspectRatioNumber(asset.aspectRatio); + if (aspect > 1.15) { + return shouldPreserveLandscapeViewport(asset, opts.shot) + ? 'landscape_viewport' + : 'portrait_safe_crop'; + } + if (aspect > 0 && aspect < 0.9) return 'portrait_cover'; + return 'portrait_cover'; } -function createSegmentFill(opts: { - segmentRole: z.infer; - slotId: string; +function shouldPreserveLandscapeViewport(asset: TaggedAsset, shot?: DirectorPlanShot): boolean { + const functions = new Set([ + ...(asset.visualFunctions ?? []), + ...(shot?.visualFunctions ?? []), + ]); + if ( + functions.has('establish_context') || + functions.has('show_scale') || + functions.has('show_emotion') || + functions.has('bridge_transition') + ) { + return true; + } + if (asset.shotScale === 'wide' || shot?.shotScale === 'wide' || shot?.visualRole === 'establishing') return true; + return /风景|远景|全景|山|湖|海|天空|瀑布|景区|旅行|scenic|landscape|wide/i.test(asset.summary); +} + +function buildVisualCoverageReport(opts: { + directorPlan: DirectorPlan; + assets: TaggedAsset[]; + aspectMismatch: boolean; + referenceClipAllowed: boolean; + topic: string; + sellingPoints: string[]; +}): VisualCoverageReport { + const required = opts.directorPlan.shots.flatMap((shot) => shot.visualFunctions ?? []); + const requiredFunctions = Array.from(new Set(required)); + const visualAssets = reusableAssets(opts.assets); + const context = referenceContextText(opts.topic, opts.sellingPoints); + const detailItems = requiredFunctions.map((visualFunction) => { + const requiredShots = opts.directorPlan.shots.filter((shot) => shot.visualFunctions.includes(visualFunction)).length; + const exactCoveredAssets = visualAssets.filter((asset) => (asset.visualFunctions ?? []).includes(visualFunction)).length; + const weakBridgeAssets = exactCoveredAssets > 0 + ? 0 + : visualAssets.filter((asset) => canWeaklyCoverBridgeFunction(asset, visualFunction, context)).length; + const coveredAssets = exactCoveredAssets + weakBridgeAssets; + const status: VisualCoverageItem['status'] = + coveredAssets <= 0 + ? 'missing' + : weakBridgeAssets > 0 || (coveredAssets === 1 && requiredShots > 1) + ? 'weak' + : 'covered'; + return VisualCoverageItem.parse({ + visualFunction, + requiredShots, + coveredAssets, + status, + note: + status === 'missing' + ? `缺少 ${visualFunction} 功能素材。` + : status === 'weak' + ? weakBridgeAssets > 0 + ? `${visualFunction} 由用户实景 b-roll 弱承接,可避免借用不相关样例画面,但仍建议补充明确标签 / 高光素材。` + : `${visualFunction} 只有 1 个候选素材,容易重复。` + : `${visualFunction} 可由用户素材支撑。`, + }); + }); + const coveredFunctions = detailItems.filter((item) => item.status === 'covered').map((item) => item.visualFunction); + const weakFunctions = detailItems.filter((item) => item.status === 'weak').map((item) => item.visualFunction); + const missingFunctions = detailItems.filter((item) => item.status === 'missing').map((item) => item.visualFunction); + const closeUpLike = visualAssets.filter((asset) => { + const functions = asset.visualFunctions ?? []; + if (asset.shotScale === 'close' || asset.shotScale === 'macro') return true; + if (asset.shotScale === 'wide') return false; + return functions.includes('show_detail') && !functions.includes('establish_context'); + }).length; + const closeUpLikeShare = visualAssets.length ? round(closeUpLike / visualAssets.length) : 0; + const recommendation = missingFunctions.length + ? opts.referenceClipAllowed + ? `用户素材缺少 ${missingFunctions.join('/')};允许 reference_clip 时可优先用参考案例补足这些视觉功能。` + : `用户素材缺少 ${missingFunctions.join('/')};建议补充素材、允许 reference_clip / stock / AIGC,或把视频降级为细节型短片。` + : weakFunctions.length + ? `视觉功能 ${weakFunctions.join('/')} 覆盖偏弱,需要控制重复并补充替代画面。` + : closeUpLikeShare > 0.72 + ? '用户素材大多是近景 / 细节 / 过程画面,完整叙事容易偏局部特写。' + : '用户素材覆盖能支撑当前 DirectorPlan。'; + return VisualCoverageReport.parse({ + requiredFunctions, + coveredFunctions, + missingFunctions, + weakFunctions, + detailItems, + closeUpLikeShare, + aspectMismatch: opts.aspectMismatch, + referenceClipAllowed: opts.referenceClipAllowed, + recommendation, + }); +} + +function referenceContextText(topic: string, sellingPoints: string[]): string { + return `${topic} ${sellingPoints.join(' ')}`.toLowerCase(); +} + +function filterReferenceAssetsForVisualBridge( + assets: ReferenceAsset[], + topic: string, + sellingPoints: string[], +): ReferenceAsset[] { + const context = referenceContextText(topic, sellingPoints); + return assets.filter((asset) => referenceAssetAllowedForContext(asset, context)); +} + +function referenceAssetAllowedForContext(asset: ReferenceAsset, context: string): boolean { + if (!referenceAssetSafeForVisualBridge(asset)) return false; + if (asset.sourceRole === 'current_sample') return true; + if (!requiresScenicReferenceRelevance(context)) return true; + return referenceAssetContextScore(asset, context) >= 1.4; +} + +function referenceAssetSafeForVisualBridge(asset: ReferenceAsset): boolean { + const functions = asset.visualFunctions ?? []; + if (functions.includes('introduce_subject')) return false; + if (asset.assetTags.includes('talking_head')) return false; + const text = normalizedAssetText(asset); + if (REFERENCE_PERSONLIKE_TEXT.test(text)) return false; + if (REFERENCE_PLATFORM_TEXT.test(text)) return false; + if ((asset.visualMood ?? []).some((mood) => REFERENCE_PERSONLIKE_TEXT.test(mood))) return false; + if (asset.visualClusterId && REFERENCE_PERSONLIKE_CLUSTER.test(asset.visualClusterId)) return false; + return true; +} + +function referenceAssetContextScore(asset: ReferenceAsset, context: string): number { + const text = `${normalizedAssetText(asset)} ${asset.patternId ?? ''} ${asset.sourceSampleId ?? ''}`.toLowerCase(); + let score = referenceSourcePriority(asset); + if (scenicOrTravelText(text)) score += 1.8; + const tokenScore = topicTokenOverlapScore(context, text); + if (tokenScore > 0) score += tokenScore; + if ((asset.visualMood ?? []).some((mood) => /(scenic|calm|travel|landscape|payoff)/i.test(mood))) score += 1.1; + if (asset.visualClusterId && /(blue_water|waterfall|landscape|sky|mountain|forest|scenic)/i.test(asset.visualClusterId)) { + score += 1.1; + } + if (asset.shotScale === 'wide' && (asset.visualFunctions ?? []).some((fn) => fn === 'establish_context' || fn === 'show_scale')) { + score += 0.5; + } + if (requiresScenicReferenceRelevance(context) && unrelatedTutorialReferenceText(text)) score -= 1.6; + return score; +} + +function referenceSourcePriority(asset: ReferenceAsset): number { + return asset.sourceRole === 'current_sample' ? 2.4 : 0; +} + +function requiresScenicReferenceRelevance(context: string): boolean { + return scenicOrTravelText(context); +} + +function scenicOrTravelText(text: string): boolean { + return /(旅行|旅游|vlog|景区|城市漫游|徒步|露营|九寨沟|黄龙|山|湖|海|瀑布|森林|草原|天空|风景|景色|旅拍|travel|trip|tour|scenic|landscape|hike|lake|waterfall|mountain|forest|sky|view)/i.test(text); +} + +function unrelatedTutorialReferenceText(text: string): boolean { + return /(教程|教学|搜索|账号|转场|碎裂|平台|引导|tutorial|search|transition|effect|account)/i.test(text); +} + +function topicTokenOverlapScore(context: string, text: string): number { + const tokens = context + .split(/[^a-z0-9\u4e00-\u9fa5]+/i) + .map((token) => token.trim()) + .filter((token) => token.length >= 2 && !/(vlog|旅行|旅游|记录|视频|素材|travel|trip|tour)/i.test(token)); + if (!tokens.length) return 0; + const overlap = tokens.filter((token) => text.includes(token)).length; + return Math.min(1.4, overlap * 0.7); +} + +function canWeaklyCoverBridgeFunction(asset: TaggedAsset, visualFunction: VisualFunctionT, context: string): boolean { + if (!REFERENCE_BRIDGE_ALLOWED_FUNCTIONS.has(visualFunction)) return false; + if (!requiresScenicReferenceRelevance(context)) return false; + if (asset.mediaType !== 'image') return false; + if (!asset.assetTags.includes('b_roll')) return false; + const functions = asset.visualFunctions ?? []; + if (functions.includes(visualFunction)) return true; + if (functions.some((fn) => REFERENCE_BRIDGE_BLOCKED_FUNCTIONS.has(fn) && fn !== 'show_action')) return false; + const text = normalizedAssetText(asset); + if (/(自拍|脸|近脸|脚|地面|路面|手部|特写|微距|selfie|face|feet|ground|floor|hand|close|macro)/i.test(text)) { + return false; + } + return true; +} + +function templateAwareMotionPreset( + base: MotionPreset | undefined, + templateProfile: TemplateProfile | undefined, + track: TimelineItem['track'], + asset: TaggedAsset | undefined, + isGeneratedCard: boolean, + visualIndex: number, +): MotionPreset | undefined { + if (!templateProfile || isGeneratedCard || track === 'audio' || track === 'text') return base; + if (asset?.mediaType !== 'image' && asset?.mediaType !== 'video') return base; + const preferred = templateProfile.motionLanguage.preferredMotionPreset as MotionPreset; + if (templateProfile.motionLanguage.internalMotionIntensity === 'high') { + return visualIndex % 2 === 0 ? preferred : base === 'static' ? 'reveal_pan' : (base ?? preferred); + } + if (templateProfile.motionLanguage.hasViewportSlides && (!base || base === 'static' || base === 'ken_burns_in')) { + return preferred === 'beat_pulse' ? 'reveal_pan' : preferred; + } + return base; +} + +function templateAwareTransitionPreset( + base: TransitionPreset | undefined, + templateProfile: TemplateProfile | undefined, + track: TimelineItem['track'], + isGeneratedCard: boolean, +): TransitionPreset | undefined { + if (!templateProfile || isGeneratedCard || track === 'audio' || track === 'text') return base; + if (!templateProfile.motionLanguage.hasMaskReveals && !templateProfile.motionLanguage.hasViewportSlides) return base; + if (base && base !== 'cut') return base; + return templateProfile.motionLanguage.preferredTransitionPreset as TransitionPreset; +} + +function selectBgmSource( + assets: TaggedAsset[], + referenceAssets: ReferenceAsset[], + templateProfile?: TemplateProfile, + opts: { + allowAutoReuse: boolean; + sampleAnalysis?: SampleAnalysis; + } = { allowAutoReuse: true }, +): { + source: TimelineSource; + ref: string; + label: string; + sourceKindLabel: string; +} | undefined { + const explicitBgmAudio = assets.find((asset) => asset.isBgm && asset.mediaType === 'audio' && hasUsableAudio(asset)); + if (explicitBgmAudio) { + return { + source: { kind: 'user_asset', assetId: explicitBgmAudio.id }, + ref: explicitBgmAudio.id, + label: `指定 BGM ${explicitBgmAudio.summary || explicitBgmAudio.id}`, + sourceKindLabel: '用户指定 BGM 音频', + }; + } + + const explicitBgmVideo = assets.find((asset) => asset.isBgm && asset.mediaType === 'video' && hasUsableAudio(asset)); + if (explicitBgmVideo) { + return { + source: { kind: 'user_asset', assetId: explicitBgmVideo.id }, + ref: explicitBgmVideo.id, + label: `指定 BGM 视频原声 ${explicitBgmVideo.summary || explicitBgmVideo.id}`, + sourceKindLabel: '用户指定 BGM 视频原声', + }; + } + + if (!opts.allowAutoReuse) return undefined; + + const preferSampleAudio = isCarouselTemplateProfile(templateProfile); + const currentSampleAudio = referenceAssets.find( + (asset) => asset.sourceRole === 'current_sample' && asset.mediaType === 'video' && hasUsableAudio(asset) && Boolean(asset.sourcePath), + ); + if (preferSampleAudio && currentSampleAudio?.sourcePath) { + return { + source: { kind: 'raw', path: currentSampleAudio.sourcePath }, + ref: currentSampleAudio.id, + label: `当前样例短模板音频 ${currentSampleAudio.id}`, + sourceKindLabel: '样例模板音频', + }; + } + + const uploadedAudio = assets.find((asset) => asset.mediaType === 'audio' && hasUsableAudio(asset)); + if (uploadedAudio) { + return { + source: { kind: 'user_asset', assetId: uploadedAudio.id }, + ref: uploadedAudio.id, + label: `音频素材 ${uploadedAudio.id}`, + sourceKindLabel: '上传音频素材', + }; + } + + const uploadedVideoWithAudio = assets.find( + (asset) => asset.mediaType === 'video' && hasUsableAudio(asset), + ); + if (uploadedVideoWithAudio) { + return { + source: { kind: 'user_asset', assetId: uploadedVideoWithAudio.id }, + ref: uploadedVideoWithAudio.id, + label: `视频原声 ${uploadedVideoWithAudio.id}`, + sourceKindLabel: '上传视频原声', + }; + } + + const referenceWithAudio = referenceAssets.find( + (asset) => asset.mediaType === 'video' && hasUsableAudio(asset) && Boolean(asset.sourcePath), + ); + const sampleAudioPath = opts.sampleAnalysis?.metadata.hasAudio === false ? undefined : opts.sampleAnalysis?.sourcePath; + if (sampleAudioPath) { + return { + source: { kind: 'raw', path: sampleAudioPath }, + ref: opts.sampleAnalysis?.sampleId ?? sampleAudioPath, + label: `当前样例音频 ${opts.sampleAnalysis?.sampleId ?? sampleAudioPath}`, + sourceKindLabel: '当前样例音频', + }; + } + + if (referenceWithAudio?.sourcePath) { + return { + source: { kind: 'raw', path: referenceWithAudio.sourcePath }, + ref: referenceWithAudio.id, + label: `${referenceWithAudio.sourceRole === 'current_sample' ? '当前样例音频' : '学习样例音频'} ${referenceWithAudio.id}`, + sourceKindLabel: '样例视频音频', + }; + } + + return undefined; +} + +export function hasUsableAudio(asset: Pick): boolean { + if (asset.hasAudio === false) return false; + if (asset.silentAudioRisk === true) return false; + if (asset.audioMaxVolumeDb != null && asset.audioMaxVolumeDb <= SILENT_AUDIO_MAX_VOLUME_DB) return false; + return true; +} + +function createGapFill(opts: { + gap: Gap; + slot: StructureSlot | undefined; + assets: TaggedAsset[]; + referenceAssets: ReferenceAsset[]; + usageByAsset: Map; topic: string; sellingPoints: string[]; startSec: number; endSec: number; index: number; + visualGapMode: z.infer; + requiredVisualFunctions: VisualFunctionT[]; }): FillArtifact { - return { - id: `fill_${opts.segmentRole}_${opts.index + 1}`, - slotId: opts.slotId, - kind: 'text_card', - source: `textcard://${encodeURIComponent(scriptText(opts.segmentRole, opts.topic, opts.sellingPoints, ''))}`, - track: 'video', - startSec: opts.startSec, - endSec: opts.endSec, + const rawStrategies = opts.gap.recommendedStrategies.length + ? opts.gap.recommendedStrategies + : (['copy_completion', 'packaging_overlay', 'aigc_image', 'reused_clip', 'reference_clip'] as FillArtifact['kind'][]); + const visualGap = + opts.slot != null && + !opts.slot.requiredAssetTypes.some((tag) => tag === 'text_card' || tag === 'talking_head'); + const longVisualGap = visualGap && opts.endSec - opts.startSec > 4.2; + const hasReference = opts.referenceAssets.some((asset) => asset.sourcePath); + const strategies = longVisualGap && hasReference + ? prioritizeReferenceWhenCompletionWouldDrag(rawStrategies) + : rawStrategies; + for (const strategy of strategies) { + const fill = createFillForStrategy({ + strategy, + slotId: opts.gap.slotId, + assets: opts.assets, + referenceAssets: opts.referenceAssets, + usageByAsset: opts.usageByAsset, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + startSec: opts.startSec, + endSec: opts.endSec, + index: opts.index, + visualGapMode: opts.visualGapMode, + requiredVisualFunctions: opts.requiredVisualFunctions, + }); + if (fill) return fill; + } + + return createTextualFill({ + kind: 'copy_completion', + slotId: opts.gap.slotId, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + startSec: opts.startSec, + endSec: opts.endSec, + index: opts.index, + }); +} + +function prioritizeReferenceWhenCompletionWouldDrag(strategies: FillArtifact['kind'][]): FillArtifact['kind'][] { + const preferredWhenStaticFillWouldDrag: FillArtifact['kind'][] = [ + 'aigc_clip', + 'aigc_image', + 'reference_clip', + 'reused_clip', + 'copy_completion', + 'packaging_overlay', + 'text_card', + ]; + return [ + ...preferredWhenStaticFillWouldDrag.filter((strategy) => strategies.includes(strategy)), + ...strategies.filter((strategy) => !preferredWhenStaticFillWouldDrag.includes(strategy)), + ]; +} + +function createSegmentFill(opts: { + segmentRole: z.infer; + slotId: string; + topic: string; + sellingPoints: string[]; + startSec: number; + endSec: number; + index: number; + assets?: TaggedAsset[]; + referenceAssets?: ReferenceAsset[]; + usageByAsset?: Map; + preferredStrategies?: SourcePreference[]; + visualGapMode?: z.infer; + requiredVisualFunctions?: VisualFunctionT[]; +}): FillArtifact { + const strategies: FillArtifact['kind'][] = opts.preferredStrategies?.length + ? fillStrategiesFromSourcePreferences(opts.preferredStrategies) + : [ + 'copy_completion', + 'packaging_overlay', + 'aigc_image', + 'aigc_clip', + 'reused_clip', + 'reference_clip', + ]; + for (const strategy of strategies) { + const fill = createFillForStrategy({ + strategy, + slotId: opts.slotId, + assets: opts.assets ?? [], + referenceAssets: opts.referenceAssets ?? [], + usageByAsset: opts.usageByAsset ?? new Map(), + topic: opts.topic, + sellingPoints: opts.sellingPoints, + startSec: opts.startSec, + endSec: opts.endSec, + index: opts.index, + visualGapMode: opts.visualGapMode ?? 'smart_fill', + requiredVisualFunctions: opts.requiredVisualFunctions ?? [], + }); + if (fill) return fill; + } + return createTextualFill({ + kind: 'copy_completion', + slotId: opts.slotId, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + startSec: opts.startSec, + endSec: opts.endSec, + index: opts.index, + }); +} + +function fillStrategiesFromSourcePreferences(preferences: SourcePreference[]): FillArtifact['kind'][] { + const mapped = preferences.flatMap((preference) => { + if (preference === 'reference_clip') return ['reference_clip']; + if (preference === 'reused_clip' || preference === 'user_asset') return ['reused_clip']; + if (preference === 'aigc') return ['aigc_clip', 'aigc_image']; + if (preference === 'text_card') return ['text_card']; + if (preference === 'packaging_overlay') return ['packaging_overlay']; + return ['copy_completion']; + }); + return Array.from(new Set([...mapped, 'copy_completion', 'packaging_overlay'])); +} + +function coverageAwareFallbackStrategies( + preferences: SourcePreference[], + visualGapMode: z.infer, + requiredVisualFunctions: VisualFunctionT[], +): SourcePreference[] { + const set = new Set(preferences); + const ordered: SourcePreference[] = [ + ...(visualGapMode === 'reference_bridge' && + set.has('reference_clip') && + canUseReferenceBridgeFor(requiredVisualFunctions) + ? (['reference_clip'] as SourcePreference[]) + : []), + ...(visualGapMode !== 'user_only' && set.has('aigc') ? (['aigc'] as SourcePreference[]) : []), + ...(set.has('packaging_overlay') ? (['packaging_overlay'] as SourcePreference[]) : []), + ...(set.has('copy_completion') || set.has('structure_reframe') + ? (['copy_completion'] as SourcePreference[]) + : []), + ...(set.has('text_card') ? (['text_card'] as SourcePreference[]) : []), + ...(set.has('reused_clip') || set.has('user_asset') ? (['reused_clip'] as SourcePreference[]) : []), + ...preferences.filter((preference) => preference !== 'user_asset'), + ]; + return Array.from(new Set(ordered)); +} + +function createFillForStrategy(opts: { + strategy: FillArtifact['kind']; + slotId: string; + assets: TaggedAsset[]; + referenceAssets: ReferenceAsset[]; + usageByAsset: Map; + topic: string; + sellingPoints: string[]; + startSec: number; + endSec: number; + index: number; + visualGapMode: z.infer; + requiredVisualFunctions: VisualFunctionT[]; +}): FillArtifact | undefined { + if (opts.strategy === 'reused_clip') { + const asset = pickReusableAsset(opts.assets, opts.usageByAsset); + if (!asset) return undefined; + incrementUse(opts.usageByAsset, asset.id); + return { + id: `fill_${safeId(opts.slotId)}_${opts.index + 1}`, + slotId: opts.slotId, + kind: 'reused_clip', + source: `${REUSED_ASSET_URI}${encodeURIComponent(asset.id)}`, + track: 'video', + startSec: opts.startSec, + endSec: opts.endSec, + }; + } + + if (opts.strategy === 'reference_clip') { + if (opts.visualGapMode !== 'reference_bridge' || !canUseReferenceBridgeFor(opts.requiredVisualFunctions)) { + return undefined; + } + const asset = pickReferenceAsset(opts.referenceAssets, opts.requiredVisualFunctions, opts.topic, opts.sellingPoints); + if (!asset?.sourcePath) return undefined; + return { + id: `fill_ref_${safeId(opts.slotId)}_${opts.index + 1}`, + slotId: opts.slotId, + kind: 'reference_clip', + source: asset.sourcePath, + track: 'video', + startSec: opts.startSec, + endSec: opts.endSec, + }; + } + + if ( + opts.strategy === 'copy_completion' || + opts.strategy === 'text_card' || + opts.strategy === 'packaging_overlay' + ) { + return createTextualFill({ + kind: opts.strategy, + slotId: opts.slotId, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + startSec: opts.startSec, + endSec: opts.endSec, + index: opts.index, + }); + } + + if (opts.strategy === 'aigc_image' || opts.strategy === 'aigc_clip') { + return undefined; + } + + return undefined; +} + +function createTextualFill(opts: { + kind: FillArtifact['kind']; + slotId: string; + topic: string; + sellingPoints: string[]; + startSec: number; + endSec: number; + index: number; +}): FillArtifact { + const isAigc = opts.kind === 'aigc_clip' || opts.kind === 'aigc_image'; + const rawText = isAigc + ? `AIGC 生成占位\n${fillText(opts.topic, opts.sellingPoints)}` + : fillText(opts.topic, opts.sellingPoints); + const text = sanitizeViewerCopy(rawText, { + topic: opts.topic, + sellingPoints: opts.sellingPoints, + }).text; + const id = `fill_${safeId(opts.slotId)}_${opts.index + 1}`; + return { + id, + slotId: opts.slotId, + kind: opts.kind, + source: `textcard://generated-card-${opts.index + 1}`, + displayText: text, + debugLabel: `补全 ${opts.slotId}`, + track: 'video', + startSec: opts.startSec, + endSec: opts.endSec, + }; +} + +function enrichTaggedAssetForStory(asset: TaggedAsset): TaggedAsset { + if (asset.mediaType !== 'video' && asset.mediaType !== 'image') return asset; + const text = normalizedAssetText(asset); + const inferredRoles = asset.storyRoles?.length ? [] : inferStoryRoles(asset, text); + const mergedRoles = uniqueStoryFunctions([...(asset.storyRoles ?? []), ...inferredRoles]); + const visualFunctions = asset.visualFunctions?.length + ? asset.visualFunctions + : inferVisualFunctions(asset, text, mergedRoles); + return { + ...asset, + storyRoles: mergedRoles.length ? mergedRoles : asset.storyRoles, + narrativeUse: asset.narrativeUse ?? mergedRoles[0], + visualFunctions, + shotScale: asset.shotScale ?? inferAssetShotScale(text), + visualMood: asset.visualMood?.length ? asset.visualMood : inferVisualMood(text), + visualClusterId: asset.visualClusterId ?? inferVisualClusterId(asset, text), + qualityScore: asset.qualityScore, + highlightWindows: asset.highlightWindows?.length ? asset.highlightWindows : undefined, + safeCropPreset: asset.safeCropPreset ?? inferSafeCropPreset(asset, text), + }; +} + +function normalizedAssetText(asset: TaggedAsset): string { + return `${asset.summary} ${asset.assetTags.join(' ')} ${(asset.visualFunctions ?? []).join(' ')} ${asset.shotScale ?? ''} ${(asset.visualMood ?? []).join(' ')}`.toLowerCase(); +} + +function inferStoryRoles(asset: TaggedAsset, text: string): StoryFunction[] { + const roles: StoryFunction[] = []; + if (/(人|人物|脸|自拍|合影|游客|person|portrait|face|selfie|couple|family)/i.test(text)) { + roles.push('character', 'context'); + } + if (/(湖|水|海|天空|山|森林|瀑布|草原|风景|lake|water|sky|mountain|forest|waterfall|landscape|wide)/i.test(text)) { + roles.push('opening_hook', 'mood', 'context'); + } + if (/(特写|近景|细节|树枝|纹理|质感|close|detail|macro|texture)/i.test(text)) { + roles.push('detail'); + } + if (/(动作|走|拍照|体验|旅行|使用|walking|travel|action|demo|use)/i.test(text) || asset.assetTags.includes('usage_demo')) { + roles.push('action'); + } + if (/(结果|证明|到达|开阔|瀑布|高光|payoff|proof|arrival|view|highlight)/i.test(text)) { + roles.push('proof', 'payoff'); + } + if (/(搜索|平台|音乐|二维码|cta|购买|领取|search|download)/i.test(text) || asset.assetTags.includes('text_card')) { + roles.push('cta'); + } + return roles; +} + +function inferVisualFunctions(asset: TaggedAsset, text: string, roles: StoryFunction[]): VisualFunctionT[] { + const functions: VisualFunctionT[] = []; + if (/(远景|全景|环境|场景|地点|空间|山|海|湖|天空|城市|风景|wide|landscape|establish|scene|place)/i.test(text)) { + functions.push('establish_context', 'show_scale'); + } + if (/(人|人物|脸|自拍|合影|主角|portrait|person|face|selfie|character)/i.test(text)) { + functions.push('introduce_subject', 'show_emotion'); + } + if (/(动作|过程|使用|操作|倒|搅拌|萃取|走|体验|walking|action|demo|use|pour|make|brew)/i.test(text) || asset.assetTags.includes('usage_demo')) { + functions.push('show_action', 'show_progression'); + } + if (/(特写|近景|细节|质感|纹理|咖啡豆|豆|杯|液体|close|detail|macro|texture|bean|espresso)/i.test(text) || asset.assetTags.includes('product_closeup')) { + functions.push('show_detail'); + } + if (/(结果|完成|成品|拉花|证明|到达|高光|payoff|result|after|latte|hero|highlight)/i.test(text)) { + functions.push('show_result'); + } + if (/(氛围|情绪|生活|办公室|朋友|家庭|放松|mood|emotion|lifestyle|office|family|friends)/i.test(text)) { + functions.push('show_emotion'); + } + if (/(转场|空镜|过渡|transition|bridge)/i.test(text)) functions.push('bridge_transition'); + if (/(cta|购买|关注|收藏|搜索|二维码|download|buy|follow)/i.test(text) || asset.assetTags.includes('text_card')) { + functions.push('call_to_action'); + } + roles.forEach((role) => { + if (role === 'opening_hook' || role === 'context') functions.push('establish_context'); + if (role === 'character') functions.push('introduce_subject'); + if (role === 'action' || role === 'turn') functions.push('show_action'); + if (role === 'detail' || role === 'contrast') functions.push('show_detail'); + if (role === 'proof' || role === 'payoff') functions.push('show_result'); + if (role === 'mood') functions.push('show_emotion'); + if (role === 'transition') functions.push('bridge_transition'); + if (role === 'cta') functions.push('call_to_action'); + }); + return Array.from(new Set(functions.length ? functions : ['show_action'])); +} + +function inferAssetShotScale(text: string): TaggedAsset['shotScale'] { + if (/(远景|全景|环境|风景|wide|landscape|establish)/i.test(text)) return 'wide'; + if (/(微距|极近|macro)/i.test(text)) return 'macro'; + if (/(特写|近景|细节|close|detail|手|咖啡豆|豆|液体|espresso|bean)/i.test(text)) return 'close'; + return 'medium'; +} + +function inferSafeCropPreset(asset: TaggedAsset, text: string): TaggedAsset['safeCropPreset'] { + if (/(人|人物|脸|自拍|合影|portrait|person|face|selfie)/i.test(text)) return 'top'; + if (/(商品|产品|杯|咖啡|特写|细节|product|close|detail|macro|latte|espresso)/i.test(text)) return 'closeup'; + if (/(手|动作|walking|action|demo|use|hand)/i.test(text)) return 'center'; + if (/(远景|全景|风景|landscape|wide|mountain|lake|sky)/i.test(text)) return 'center'; + void asset; + return undefined; +} + +function uniqueStoryFunctions(roles: StoryFunction[]): StoryFunction[] { + return Array.from(new Set(roles)); +} + +function inferVisualMood(text: string): string[] { + const moods: string[] = []; + if (/(湖|水|海|天空|山|森林|草原|风景|lake|water|sky|mountain|forest|landscape)/i.test(text)) moods.push('scenic'); + if (/(蓝|绿|春|清澈|治愈|松弛|blue|green|calm|healing)/i.test(text)) moods.push('calm'); + if (/(人|人物|自拍|合影|portrait|face|selfie)/i.test(text)) moods.push('human'); + if (/(瀑布|高光|结果|arrival|highlight|waterfall)/i.test(text)) moods.push('payoff'); + return Array.from(new Set(moods)); +} + +function inferVisualClusterId(asset: TaggedAsset, text: string): string { + if (/(手|手部|倒豆|咖啡豆|豆|hand|bean)/i.test(text)) return 'scene:hand_detail'; + if (/(咖啡机|萃取|浓缩|espresso|machine|pour)/i.test(text)) return 'scene:coffee_process_closeup'; + if (/(拉花|拿铁|成品|latte|result)/i.test(text)) return 'scene:coffee_result'; + if (/(瀑布|waterfall)/i.test(text)) return 'scene:waterfall'; + if (/(湖|水|海|清澈|蓝|lake|water|sea|blue)/i.test(text)) return 'scene:blue_water'; + if (/(天空|树枝|枝叶|sky|branch|leaf)/i.test(text)) return 'scene:sky_branch'; + if (/(人|人物|脸|自拍|合影|person|portrait|face|selfie|couple|family)/i.test(text)) return 'scene:person'; + if (/(山|森林|草原|绿|mountain|forest|grass|green|landscape)/i.test(text)) return 'scene:landscape'; + return `asset:${asset.id}`; +} + +function visualClusterKey(asset: TaggedAsset): string { + return asset.visualClusterId ? `cluster:${asset.visualClusterId}` : `asset:${asset.id}`; +} + +function keyStoryFunctions(): Set { + return new Set(['opening_hook', 'context', 'detail', 'proof', 'payoff']); +} + +function recommendedStrategiesForSlot( + slot: StructureSlot, + hasReusableAsset: boolean, + hasReferenceAsset: boolean, +): FillArtifact['kind'][] { + const completionFirst: FillArtifact['kind'][] = [ + 'copy_completion', + 'packaging_overlay', + 'aigc_image', + 'aigc_clip', + ]; + const existingFallbacks: FillArtifact['kind'][] = [ + ...(hasReusableAsset ? (['reused_clip'] as FillArtifact['kind'][]) : []), + ...(hasReferenceAsset ? (['reference_clip'] as FillArtifact['kind'][]) : []), + ]; + if (slot.segmentRole === 'hook' && slot.requiredAssetTypes.includes('talking_head') && hasReusableAsset) { + return [...existingFallbacks, ...completionFirst]; + } + if (slot.requiredAssetTypes.includes('text_card') && hasReusableAsset) { + return [...existingFallbacks, ...completionFirst]; + } + if (slot.requiredAssetTypes.includes('text_card') || slot.requiredAssetTypes.includes('talking_head')) { + return [...completionFirst, ...existingFallbacks]; + } + return [...completionFirst, ...existingFallbacks]; +} + +function strategiesForVisualGapMode( + strategies: FillArtifact['kind'][], + visualGapMode: z.infer, + requiredVisualFunctions: VisualFunctionT[], +): FillArtifact['kind'][] { + const filtered = strategies.filter((strategy) => { + if (strategy === 'reference_clip') { + return visualGapMode === 'reference_bridge' && canUseReferenceBridgeFor(requiredVisualFunctions); + } + if (strategy === 'aigc_clip' || strategy === 'aigc_image') return visualGapMode !== 'user_only'; + return true; + }); + if (visualGapMode === 'reference_bridge' && canUseReferenceBridgeFor(requiredVisualFunctions)) { + const withoutReference = filtered.filter((strategy) => strategy !== 'reference_clip'); + return ['reference_clip', ...withoutReference]; + } + return filtered; +} + +function canUseReferenceBridgeFor(requiredVisualFunctions: VisualFunctionT[]): boolean { + if (!requiredVisualFunctions.length) return false; + return requiredVisualFunctions.some((fn) => REFERENCE_BRIDGE_ALLOWED_FUNCTIONS.has(fn)) && + !requiredVisualFunctions.some((fn) => REFERENCE_BRIDGE_BLOCKED_FUNCTIONS.has(fn)); +} + +function mixedReferenceBridgeForShot( + shot: DirectorPlanShot, + visualGapMode: z.infer, + referenceAssets: ReferenceAsset[], +): { bridgeFunctions: VisualFunctionT[]; remainderFunctions: VisualFunctionT[] } | undefined { + if (visualGapMode !== 'reference_bridge') return undefined; + if (!shot.fallbackStrategies.includes('reference_clip')) return undefined; + if (!referenceAssets.some((asset) => asset.sourcePath && (asset.mediaType === 'video' || asset.mediaType === 'image'))) { + return undefined; + } + const visualFunctions = shot.visualFunctions ?? []; + const bridgeFunctions = visualFunctions.filter((fn) => REFERENCE_BRIDGE_ALLOWED_FUNCTIONS.has(fn)); + const remainderFunctions = visualFunctions.filter((fn) => REFERENCE_BRIDGE_BLOCKED_FUNCTIONS.has(fn)); + if (!bridgeFunctions.length || !remainderFunctions.length) return undefined; + return { + bridgeFunctions: Array.from(new Set(bridgeFunctions)), + remainderFunctions: Array.from(new Set(remainderFunctions)), + }; +} + +function mixedReferenceBridgeEndSec( + shotSpan: ShotSpan, + maxReferenceClipSec: number, +): number | undefined { + const totalDuration = shotSpan.endSec - shotSpan.startSec; + const minRemainderSec = 0.5; + if (totalDuration < 1.2) return undefined; + const bridgeDuration = Math.min( + maxReferenceClipSec, + Math.max(0.6, totalDuration * 0.45), + totalDuration - minRemainderSec, + ); + if (bridgeDuration < 0.5) return undefined; + return round(shotSpan.startSec + bridgeDuration); +} + +function pickReusableAsset(assets: TaggedAsset[], usageByAsset: Map): TaggedAsset | undefined { + return reusableAssets(assets).sort( + (a, b) => (usageByAsset.get(a.id) ?? 0) - (usageByAsset.get(b.id) ?? 0), + )[0]; +} + +function pickReferenceAsset( + assets: ReferenceAsset[], + requiredVisualFunctions: VisualFunctionT[], + topic: string, + sellingPoints: string[], +): ReferenceAsset | undefined { + const context = referenceContextText(topic, sellingPoints); + return reusableAssets(assets) + .filter((asset): asset is ReferenceAsset => Boolean((asset as ReferenceAsset).sourcePath)) + .filter((asset) => referenceAssetAllowedForContext(asset, context)) + .sort((a, b) => { + const overlapA = requiredVisualFunctions.filter((fn) => (a.visualFunctions ?? []).includes(fn)).length; + const overlapB = requiredVisualFunctions.filter((fn) => (b.visualFunctions ?? []).includes(fn)).length; + return ( + overlapB - overlapA || + referenceAssetContextScore(b, context) - referenceAssetContextScore(a, context) || + referenceSourcePriority(b) - referenceSourcePriority(a) || + (b.confidence ?? 0) - (a.confidence ?? 0) + ); + })[0]; +} + +function pickPreferredAssetIdForShot( + shot: DirectorPlanShot, + assets: TaggedAsset[], + usageByAsset: Map, + guardState: RepetitionGuardState, + durationSec: number, + topic: string, +): string | undefined { + const preferredIds = new Set(shot.preferredAssetIds); + const preferredAssetBonus = 1.2; + const candidates = reusableAssets(assets); + const eligible = candidates.filter((asset) => assetEligibleForShotFunction(shot, asset, topic)); + if (!eligible.length && strictShotVisualFunctions(shot).length > 0 && shot.fallbackStrategies.includes('reference_clip')) { + return undefined; + } + const pool = eligible.length ? eligible : candidates; + return pool + .sort((a, b) => { + const scoreA = shotAssetSelectionScore(shot, a, usageByAsset, guardState, durationSec, topic) + (preferredIds.has(a.id) ? preferredAssetBonus : 0); + const scoreB = shotAssetSelectionScore(shot, b, usageByAsset, guardState, durationSec, topic) + (preferredIds.has(b.id) ? preferredAssetBonus : 0); + return scoreB - scoreA; + })[0]?.id; +} + +function assetEligibleForShotFunction(shot: DirectorPlanShot, asset: TaggedAsset, topic: string): boolean { + const functions = asset.visualFunctions ?? []; + const required = strictShotVisualFunctions(shot); + if (!required.length) return true; + if ( + required.every((fn) => REFERENCE_BRIDGE_ALLOWED_FUNCTIONS.has(fn)) && + required.some((fn) => canWeaklyCoverBridgeFunction(asset, fn, referenceContextText(topic, []))) + ) { + return true; + } + if (!functions.length) return false; + return required.some((fn) => functions.includes(fn)); +} + +function isStrictVisualFunction(fn: VisualFunctionT): boolean { + return ['establish_context', 'introduce_subject', 'show_result', 'show_scale', 'call_to_action'].includes(fn); +} + +function strictShotVisualFunctions(shot: DirectorPlanShot): VisualFunctionT[] { + return (shot.visualFunctions ?? []).filter(isStrictVisualFunction); +} + +function shouldUseVisualCoverageFallbackForShot( + shot: DirectorPlanShot, + uncoveredStrictVisualFunctions: Set, + uncoveredVisualFunctions: Set, + visualGapMode: z.infer, +): boolean { + if (shot.assetNeed.some((tag) => tag === 'text_card' || tag === 'talking_head')) return false; + if (shot.visualRole === 'transition_card' || shot.visualRole === 'cta_card') return false; + const strictFunctions = strictShotVisualFunctions(shot); + const missingStrict = strictFunctions.filter((fn) => uncoveredStrictVisualFunctions.has(fn)); + if (strictFunctions.length && missingStrict.length === strictFunctions.length) return true; + if (visualGapMode !== 'reference_bridge') return false; + const bridgeable = (shot.visualFunctions ?? []).filter((fn) => + canUseReferenceBridgeFor([fn]) && uncoveredVisualFunctions.has(fn), + ); + return bridgeable.length > 0; +} + +function shotAssetSelectionScore( + shot: DirectorPlanShot, + asset: TaggedAsset, + usageByAsset: Map, + guardState: RepetitionGuardState, + durationSec: number, + topic: string, +): number { + const assetKey = `asset:${asset.id}`; + const clusterKey = visualClusterKey(asset); + const storyFunctions = keyStoryFunctions(); + const alreadyUsedForStory = guardState.storyFunctionAssetByKey.get(shot.storyFunction) === assetKey; + const clusterUsedForStory = guardState.storyFunctionClusterByKey.get(shot.storyFunction) === clusterKey; + const totalAssetSec = guardState.totalSecByKey.get(assetKey) ?? 0; + const totalClusterSec = guardState.totalSecByClusterKey.get(clusterKey) ?? 0; + const shortClipPenalty = durationSec <= 22 ? 0.52 : 0.35; + const storyRoles = asset.storyRoles ?? []; + const storyMatch = storyRoles.includes(shot.storyFunction) || asset.narrativeUse === shot.storyFunction; + const storyMismatchPenalty = storyRoles.length && !storyMatch ? 1.4 : 0; + const storyPenalty = storyFunctions.has(shot.storyFunction) + ? (alreadyUsedForStory ? 2.8 : 0) + (clusterUsedForStory ? 1.8 : 0) + : 0; + return ( + visualAffinityScore(shot, asset) + + visualFunctionAffinityScore(shot, asset) + + shotScaleAffinityScore(shot, asset) + + travelHookScenicPriorityScore(shot, asset, topic) + + (asset.qualityScore ?? 0.5) * 0.25 + + (storyMatch ? 1.2 : 0) + + asset.confidence - + (usageByAsset.get(asset.id) ?? 0) * 0.55 - + totalAssetSec * shortClipPenalty - + totalClusterSec * (shortClipPenalty * 0.75) - + storyMismatchPenalty - + storyPenalty + ); +} + +function visualAffinityScore(shot: DirectorPlanShot, asset: TaggedAsset): number { + const text = normalizedAssetText(asset); + let score = 0; + if (asset.storyRoles?.includes(shot.storyFunction)) score += 1.8; + if (asset.narrativeUse === shot.storyFunction) score += 1.2; + if (shot.segmentRole === 'hook' && /(开场|第一眼|冲突|抓人|抓停|opener|opening|hook|conflict)/i.test(text)) score += 1.2; + if (shot.visualRole === 'person_in_scene' && /(人|人物|person|portrait|selfie|face|couple|family)/i.test(text)) score += 0.8; + if (shot.visualRole === 'establishing' && /(远景|全景|山|海|湖|天空|城市|风景|landscape|wide|mountain|lake|sky)/i.test(text)) score += 0.8; + if (shot.visualRole === 'detail' && /(细节|特写|质感|油脂|texture|close|detail|macro|product)/i.test(text)) score += 1.8; + if (shot.visualRole === 'action' && /(过程|使用|动作|体验|walking|use|demo|action)/i.test(text)) score += 0.8; + if (shot.visualRole === 'proof' && /(对比|证明|结果|萃取|payoff|before|after|compare|proof)/i.test(text)) score += 1.8; + if (shot.shotScale === 'wide' && /(远景|全景|风景|landscape|wide|mountain|lake|sky)/i.test(text)) score += 0.4; + if (shot.shotScale === 'close' && /(特写|细节|close|detail|macro)/i.test(text)) score += 0.4; + if (asset.mediaType === 'image') score += 0.1; + return score; +} + +function travelHookScenicPriorityScore(shot: DirectorPlanShot, asset: TaggedAsset, topic: string): number { + if (shot.segmentRole !== 'hook') return 0; + const context = `${topic} ${shot.storyBeat} ${shot.visualDirection} ${shot.communicationIntent} ${shot.screenTextIntent} ${shot.mustShow}`.toLowerCase(); + if (!/(旅行|旅游|vlog|景区|城市漫游|徒步|露营|九寨沟|山|湖|海|瀑布|风景|travel|trip|tour|scenic|landscape|hike|lake|waterfall|mountain)/i.test(context)) { + return 0; + } + const storyRoles = asset.storyRoles ?? []; + if (storyRoles.length && !storyRoles.includes('opening_hook') && asset.narrativeUse !== 'opening_hook') { + return 0; + } + const text = normalizedAssetText(asset); + const functions = asset.visualFunctions ?? []; + const moods = asset.visualMood ?? []; + let score = 0; + const scenicText = /(远景|全景|环境|风景|湖|水|海|山|天空|森林|瀑布|蓝|绿|landscape|wide|lake|water|sea|mountain|sky|forest|waterfall|scenic)/i.test(text); + const ordinaryPov = /(脚|地面|路面|走路|手持|随手|pov|walking|ground|floor|feet|handheld)/i.test(text); + const closeHuman = /(自拍|脸|大头|近脸|selfie|face|portrait|close)/i.test(text) && !scenicText; + if (shot.storyFunction === 'opening_hook' && (storyRoles.includes('opening_hook') || asset.narrativeUse === 'opening_hook')) score += 3.2; + if (asset.shotScale === 'wide') score += 2.2; + if (functions.includes('establish_context')) score += 1.4; + if (functions.includes('show_scale')) score += 1.8; + if (functions.includes('show_result')) score += 0.8; + if (moods.includes('scenic') || moods.includes('calm') || moods.includes('payoff')) score += 1.2; + if (scenicText) score += 1.6; + if (asset.visualClusterId && /(blue_water|waterfall|landscape|sky|mountain|forest)/i.test(asset.visualClusterId)) score += 1.2; + if (asset.shotScale === 'close' || asset.shotScale === 'macro') score -= 1.8; + if (ordinaryPov) score -= 2.8; + if (closeHuman) score -= 1.6; + return score; +} + +function visualFunctionAffinityScore(shot: DirectorPlanShot, asset: TaggedAsset): number { + const shotFunctions = shot.visualFunctions ?? []; + const assetFunctions = asset.visualFunctions ?? []; + if (!shotFunctions.length || !assetFunctions.length) return 0; + const overlap = shotFunctions.filter((fn) => assetFunctions.includes(fn)).length; + const strictMiss = strictShotVisualFunctions(shot).length > 0 && overlap === 0; + return overlap * 1.35 - (strictMiss ? 2.4 : 0); +} + +function shotScaleAffinityScore(shot: DirectorPlanShot, asset: TaggedAsset): number { + if (!asset.shotScale) return 0; + if (shot.shotScale === asset.shotScale) return 0.8; + if (shot.shotScale === 'wide' && (asset.shotScale === 'close' || asset.shotScale === 'macro')) return -1.4; + if ((shot.shotScale === 'close' || shot.shotScale === 'macro') && asset.shotScale === 'wide') return -0.4; + return -0.25; +} + +function normalizeDirectorPlanForStoryDefault( + plan: DirectorPlan, + input: RuleBasedMigrationInput, +): DirectorPlan { + if ((input.sellingPoints ?? []).some((point) => point.trim())) return plan; + const sampleStoryArc = buildSampleStoryArc(input.blueprint, input.topic.trim() || '新主题'); + const beatByRole = new Map(sampleStoryArc.beatFunctions.map((beat) => [beat.segmentRole, beat])); + return DirectorPlan.parse({ + ...plan, + sampleStoryArc, + storyArc: { + opening: `迁移样例故事开场:${sampleStoryArc.openingQuestion}`, + setup: `迁移样例故事铺垫:${sampleStoryArc.setupSituation}`, + progression: `迁移样例故事推进:${sampleStoryArc.progressionPattern}`, + turn: `迁移样例故事转折/证明:${sampleStoryArc.turnOrProof}`, + payoff: `迁移样例故事收束:${sampleStoryArc.payoff}`, + emotionalCurve: sampleStoryArc.emotionalCurve, + }, + shots: plan.shots.map((shot) => { + const beat = beatByRole.get(shot.segmentRole); + if (!beat) return shot; + return { + ...shot, + storyBeat: beat.label, + storyFunction: beat.storyFunction, + purpose: `按样例「${beat.label}」迁移:${beat.intent}`, + screenTextIntent: + shot.segmentRole === 'hook' + ? `用类似样例「${beat.label}」的方式提出观看问题,不写通用卖点。` + : shot.screenTextIntent, + }; + }), + rationale: `${plan.rationale};无明确卖点/brief,已强制按样例故事弧线归一化 DirectorPlan。`, + }); +} + +function expandDirectorPlanForCarousel( + plan: DirectorPlan, + opts: { + templateProfile?: TemplateProfile; + assets: TaggedAsset[]; + }, +): DirectorPlan { + if (!isCarouselTemplateProfile(opts.templateProfile)) return plan; + const visualAssets = reusableAssets(opts.assets); + if (visualAssets.length < 2) return plan; + + const slideCount = carouselSlideCount(opts.templateProfile, visualAssets.length, plan.editConstraints.durationSec); + if (plan.shots.length >= slideCount) return plan; + + const shots: DirectorPlanShot[] = Array.from({ length: slideCount }, (_, index) => { + const base = plan.shots[Math.min(index, plan.shots.length - 1)]; + const startSec = round((plan.editConstraints.durationSec * index) / slideCount); + const endSec = index === slideCount - 1 + ? plan.editConstraints.durationSec + : round((plan.editConstraints.durationSec * (index + 1)) / slideCount); + const preferredAsset = visualAssets[index % visualAssets.length]; + return { + ...base, + shotId: `shot_carousel_${index + 1}`, + startSec, + endSec, + purpose: `${base.purpose}(carousel slide ${index + 1}/${slideCount})`, + visualDirection: `${base.visualDirection};轮播第 ${index + 1} 张使用 ${preferredAsset.summary || preferredAsset.id}`, + communicationIntent: index === 0 ? base.communicationIntent : '轮播后续 slide 主要靠素材变化和模板滑动推进。', + copyMode: index === 0 ? base.copyMode : 'none', + copyRequired: index === 0 ? base.copyRequired : false, + copyPurpose: index === 0 ? base.copyPurpose : undefined, + screenTextIntent: index === 0 ? base.screenTextIntent : 'visual-only:carousel 后续 slide 不重复上屏文字', + preferredAssetIds: [ + preferredAsset.id, + ...base.preferredAssetIds.filter((id) => id !== preferredAsset.id), + ].slice(0, Math.max(1, Math.min(4, visualAssets.length))), + fallbackStrategies: base.fallbackStrategies.includes('user_asset') + ? base.fallbackStrategies + : ['user_asset', ...base.fallbackStrategies], + motionPreset: index % 2 === 0 + ? (opts.templateProfile?.motionLanguage.preferredMotionPreset as MotionPreset | undefined) ?? base.motionPreset + : base.motionPreset, + transitionPreset: + index === 0 + ? base.transitionPreset + : (opts.templateProfile?.motionLanguage.preferredTransitionPreset as TransitionPreset | undefined) ?? base.transitionPreset, + mustShow: `${base.mustShow} / carousel slide ${index + 1}`, + }; + }); + + return DirectorPlan.parse({ + ...plan, + editConstraints: { + ...plan.editConstraints, + targetShotSec: round(plan.editConstraints.durationSec / slideCount), + }, + shots, + rationale: `${plan.rationale};camera carousel 模板已按可用视觉素材展开为 ${slideCount} 个 slide shot,避免单一素材撑完整模板。`, + }); +} + +function buildLowMaterialExpansionPlan(opts: { + plan: DirectorPlan; + assets: TaggedAsset[]; + templateProfile?: TemplateProfile; + preserveStoryBeatCount: boolean; +}): LowMaterialExpansionPlan { + const visualAssets = reusableAssets(opts.assets); + const videoAssets = visualAssets.filter((asset) => asset.mediaType === 'video' && (asset.durationSec ?? 0) >= 6); + const totalVisualSec = visualAssets.reduce((sum, asset) => sum + (asset.durationSec ?? 0), 0); + const durationSec = opts.plan.editConstraints.durationSec; + const targetShotCount = Math.min(8, Math.max(opts.plan.shots.length, Math.round(durationSec / 1.8), 5)); + const shouldExpand = + !isCarouselTemplateProfile(opts.templateProfile) && + !opts.preserveStoryBeatCount && + visualAssets.length > 0 && + visualAssets.length <= 2 && + videoAssets.length > 0 && + durationSec >= 8 && + totalVisualSec >= durationSec * 0.65 && + opts.plan.shots.length < targetShotCount; + if (!shouldExpand) { + return { + enabled: false, + targetShotCount: opts.plan.shots.length, + allowVideoMotion: false, + relaxTotalShare: false, + reason: '素材数量或时长足够,不启用低素材虚拟镜头扩展。', + }; + } + return { + enabled: true, + targetShotCount, + allowVideoMotion: true, + relaxTotalShare: true, + reason: `检测到仅 ${visualAssets.length} 个可复用视觉素材,且主要是长视频;已把导演方案扩展为 ${targetShotCount} 个虚拟镜头,通过不同 source window、局部裁切和轻微安全运镜提升变化。`, + }; +} + +function expandDirectorPlanForLowMaterial( + plan: DirectorPlan, + lowMaterialPlan: LowMaterialExpansionPlan, + assets: TaggedAsset[], +): DirectorPlan { + if (!lowMaterialPlan.enabled || plan.shots.length >= lowMaterialPlan.targetShotCount) return plan; + const visualAssets = reusableAssets(assets); + const seenSegment = new Set(); + const shots: DirectorPlanShot[] = Array.from({ length: lowMaterialPlan.targetShotCount }, (_, index) => { + const base = plan.shots[Math.min(plan.shots.length - 1, Math.floor((index * plan.shots.length) / lowMaterialPlan.targetShotCount))]; + const preferredAsset = visualAssets[index % Math.max(1, visualAssets.length)]; + const startSec = round((plan.editConstraints.durationSec * index) / lowMaterialPlan.targetShotCount); + const endSec = index === lowMaterialPlan.targetShotCount - 1 + ? plan.editConstraints.durationSec + : round((plan.editConstraints.durationSec * (index + 1)) / lowMaterialPlan.targetShotCount); + const firstInSegment = !seenSegment.has(base.segmentRole); + seenSegment.add(base.segmentRole); + return { + ...base, + shotId: `${base.shotId}_virtual_${index + 1}`, + startSec, + endSec, + purpose: `${base.purpose}(低素材虚拟镜头 ${index + 1}/${lowMaterialPlan.targetShotCount})`, + visualDirection: `${base.visualDirection};低素材模式:使用不同 source window / 局部裁切 / 轻微安全运镜制造镜头变化。`, + communicationIntent: firstInSegment + ? base.communicationIntent + : '同段后续虚拟镜头主要靠画面窗口、裁切和节奏推进,不重复解释。', + copyMode: firstInSegment ? base.copyMode : 'none', + copyRequired: firstInSegment ? base.copyRequired : false, + copyPurpose: firstInSegment ? base.copyPurpose : undefined, + screenTextIntent: firstInSegment ? base.screenTextIntent : 'visual-only:低素材虚拟镜头不重复上屏文字。', + preferredAssetIds: preferredAsset + ? [ + preferredAsset.id, + ...base.preferredAssetIds.filter((id) => id !== preferredAsset.id), + ].slice(0, Math.max(1, Math.min(4, visualAssets.length))) + : base.preferredAssetIds, + motionPreset: lowMaterialVideoMotionPreset(base.motionPreset, index), + cropPreset: lowMaterialCropPresetFor(base.cropPreset, preferredAsset, index + 1, lowMaterialPlan) ?? base.cropPreset, + transitionPreset: index === 0 ? base.transitionPreset : index % 4 === 0 ? 'crossfade' : base.transitionPreset, + mustShow: `${base.mustShow} / low-material virtual shot ${index + 1}`, + }; + }); + return DirectorPlan.parse({ + ...plan, + editConstraints: { + ...plan.editConstraints, + targetShotSec: round(plan.editConstraints.durationSec / lowMaterialPlan.targetShotCount), + }, + shots, + rationale: `${plan.rationale};${lowMaterialPlan.reason}`, + }); +} + +function carouselSlideCount( + templateProfile: TemplateProfile | undefined, + visualAssetCount: number, + durationSec: number, +): number { + const eventCount = templateProfile?.events.filter((event) => event.kind === 'carousel_slide' || event.kind === 'asset_swap').length ?? 0; + const eventDriven = eventCount > 0 ? eventCount + 1 : Math.round(durationSec / 1.1); + const durationDriven = Math.max(3, Math.round(durationSec / 0.85)); + return Math.max(2, Math.min(8, visualAssetCount, Math.max(eventDriven, durationDriven))); +} + +function incrementUse(usageByAsset: Map, assetId: string): void { + usageByAsset.set(assetId, (usageByAsset.get(assetId) ?? 0) + 1); +} + +function recordTimelineAssetUse( + source: TimelineSource, + sourceAsset: TaggedAsset | undefined, + usageByAsset: Map, +): void { + if (source.kind === 'user_asset') { + incrementUse(usageByAsset, source.assetId); + return; + } + if (sourceAsset) incrementUse(usageByAsset, sourceAsset.id); +} + +function createRepetitionGuardState(): RepetitionGuardState { + return { + currentKey: '', + currentRunSec: 0, + totalSecByKey: new Map(), + currentClusterKey: '', + currentClusterRunSec: 0, + totalSecByClusterKey: new Map(), + totalSecByVisualFunctionKey: new Map(), + storyFunctionAssetByKey: new Map(), + storyFunctionClusterByKey: new Map(), + guardFillCount: 0, + replacements: [], + }; +} + +function applyAssetRepetitionGuard(opts: { + state: RepetitionGuardState; + source: TimelineSource; + track: TimelineItem['track']; + sourceAsset: TaggedAsset | undefined; + slice: { startSec: number; endSec: number }; + slotId: string; + topic: string; + sellingPoints: string[]; + assets: TaggedAsset[]; + usageByAsset: Map; + durationSec: number; + index: number; + storyFunction?: StoryFunction; + lowMaterialExpansion?: LowMaterialExpansionPlan; +}): { + source: TimelineSource; + track: TimelineItem['track']; + sourceAsset: TaggedAsset | undefined; + fill?: FillArtifact; +} { + const duration = Math.max(0, opts.slice.endSec - opts.slice.startSec); + const preferredKey = visualSourceKey(opts.source, opts.sourceAsset); + if (!preferredKey) { + recordGuardSource(opts.state, fallbackSourceKey(opts.source), undefined, [], duration, opts.storyFunction); + return { source: opts.source, track: opts.track, sourceAsset: opts.sourceAsset }; + } + + const clusterKey = opts.sourceAsset ? visualClusterKey(opts.sourceAsset) : preferredKey; + const functionKeys = visualFunctionRepetitionKeys(opts.sourceAsset); + const violation = repetitionViolation( + opts.state, + preferredKey, + clusterKey, + functionKeys, + duration, + opts.durationSec, + opts.assets, + { relaxTotalShare: Boolean(opts.lowMaterialExpansion?.enabled && opts.lowMaterialExpansion.relaxTotalShare) }, + ); + const firstCriticalStoryUse = + opts.storyFunction && + keyStoryFunctions().has(opts.storyFunction) && + !opts.state.storyFunctionAssetByKey.has(opts.storyFunction); + if (violation === 'continuous_run' && preferredKey !== opts.state.currentKey) { + recordGuardSource(opts.state, preferredKey, clusterKey, functionKeys, duration, opts.storyFunction); + return { source: opts.source, track: opts.track, sourceAsset: opts.sourceAsset }; + } + if (violation === 'total_share' && firstCriticalStoryUse && preferredKey !== opts.state.currentKey) { + recordGuardSource(opts.state, preferredKey, clusterKey, functionKeys, duration, opts.storyFunction); + return { source: opts.source, track: opts.track, sourceAsset: opts.sourceAsset }; + } + if (!violation) { + recordGuardSource(opts.state, preferredKey, clusterKey, functionKeys, duration, opts.storyFunction); + return { source: opts.source, track: opts.track, sourceAsset: opts.sourceAsset }; + } + + const alternate = pickGuardAlternateAsset({ + assets: opts.assets, + state: opts.state, + originalKey: preferredKey, + usageByAsset: opts.usageByAsset, + duration, + durationSec: opts.durationSec, + storyFunction: opts.storyFunction, + }); + + if (alternate) { + const replacementKey = visualSourceKey({ kind: 'user_asset', assetId: alternate.id }, alternate)!; + const replacementClusterKey = visualClusterKey(alternate); + incrementUse(opts.usageByAsset, alternate.id); + recordGuardReplacement(opts.state, { + originalKey: preferredKey, + replacementKey, + reason: violation, + startSec: opts.slice.startSec, + endSec: opts.slice.endSec, + }); + recordGuardSource( + opts.state, + replacementKey, + replacementClusterKey, + visualFunctionRepetitionKeys(alternate), + duration, + opts.storyFunction, + ); + return { + source: { kind: 'user_asset', assetId: alternate.id }, + track: alternate.mediaType === 'text' ? 'text' : 'video', + sourceAsset: alternate, + }; + } + + const fill = createRepetitionGuardFill({ + slotId: opts.slotId, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + startSec: opts.slice.startSec, + endSec: opts.slice.endSec, + index: opts.index, + reason: violation, + replacementAsset: opts.sourceAsset, + }); + const replacementKey = `fill:${fill.id}`; + const replacementAsset = assetFromFill(fill, opts.assets); + opts.state.guardFillCount += 1; + recordGuardReplacement(opts.state, { + originalKey: preferredKey, + replacementKey, + reason: violation, + startSec: opts.slice.startSec, + endSec: opts.slice.endSec, + }); + recordGuardSource( + opts.state, + replacementKey, + replacementAsset ? visualClusterKey(replacementAsset) : undefined, + visualFunctionRepetitionKeys(replacementAsset), + duration, + opts.storyFunction, + ); + + return { + source: { kind: 'fill_artifact', fillArtifactId: fill.id }, + track: fill.track, + sourceAsset: replacementAsset, + fill, + }; +} + +function visualSourceKey(source: TimelineSource, asset: TaggedAsset | undefined): string | undefined { + if (source.kind === 'user_asset') return `asset:${source.assetId}`; + if (asset) return `asset:${asset.id}`; + return undefined; +} + +function isDuplicateEarlyCard( + source: TimelineSource, + fills: FillArtifact[], + seen: Set, + startSec: number, +): boolean { + if (startSec > 3.2) return false; + const text = cardTextForSource(source, fills); + return Boolean(text && seen.has(text)); +} + +function markCardTextSeen(source: TimelineSource, fills: FillArtifact[], seen: Set, startSec: number): void { + if (startSec > 3.2) return; + const text = cardTextForSource(source, fills); + if (text) seen.add(text); +} + +function cardTextForSource(source: TimelineSource, fills: FillArtifact[]): string | undefined { + if (source.kind !== 'fill_artifact') return undefined; + const fill = fills.find((f) => f.id === source.fillArtifactId); + return fill?.displayText?.replace(/\s+/g, ' ').trim(); +} + +function fillForTimelineSource(source: TimelineSource, fills: FillArtifact[]): FillArtifact | undefined { + if (source.kind !== 'fill_artifact') return undefined; + return fills.find((fill) => fill.id === source.fillArtifactId); +} + +function fallbackSourceKey(source: TimelineSource): string { + if (source.kind === 'fill_artifact') return `fill:${source.fillArtifactId}`; + if (source.kind === 'raw') return `raw:${source.path}`; + return `asset:${source.assetId}`; +} + +function repetitionViolation( + state: RepetitionGuardState, + key: string, + clusterKey: string, + functionKeys: string[], + duration: number, + durationSec: number, + assets: TaggedAsset[], + opts: { relaxTotalShare?: boolean } = {}, +): RepetitionGuardState['replacements'][number]['reason'] | undefined { + const currentRun = state.currentKey === key ? state.currentRunSec : 0; + if (currentRun > 0 && currentRun + duration > maxContinuousAssetSec(durationSec)) { + return 'continuous_run'; + } + const currentClusterRun = state.currentClusterKey === clusterKey ? state.currentClusterRunSec : 0; + if (currentClusterRun > 0 && currentClusterRun + duration > maxContinuousClusterSec(durationSec)) { + return 'continuous_run'; + } + + if (!opts.relaxTotalShare) { + const total = state.totalSecByKey.get(key) ?? 0; + if (total > 0 && total + duration > maxTotalAssetSec(durationSec, reusableAssets(assets).length)) { + return 'total_share'; + } + const clusterTotal = state.totalSecByClusterKey.get(clusterKey) ?? 0; + if (clusterTotal > 0 && clusterTotal + duration > maxTotalClusterSec(durationSec)) { + return 'total_share'; + } + if ( + functionKeys.some((functionKey) => { + const totalByFunction = state.totalSecByVisualFunctionKey.get(functionKey) ?? 0; + return totalByFunction > 0 && totalByFunction + duration > maxTotalVisualFunctionSec(durationSec, functionKey); + }) + ) { + return 'total_share'; + } + } + + return undefined; +} + +function pickGuardAlternateAsset(opts: { + assets: TaggedAsset[]; + state: RepetitionGuardState; + originalKey: string; + usageByAsset: Map; + duration: number; + durationSec: number; + storyFunction?: StoryFunction; +}): TaggedAsset | undefined { + const candidates = reusableAssets(opts.assets).filter((asset) => { + const key = `asset:${asset.id}`; + const clusterKey = visualClusterKey(asset); + if (key === opts.originalKey || key === opts.state.currentKey) return false; + if (clusterKey === opts.state.currentClusterKey) return false; + if ( + opts.storyFunction && + keyStoryFunctions().has(opts.storyFunction) && + opts.state.storyFunctionClusterByKey.get(opts.storyFunction) === clusterKey + ) { + return false; + } + const total = opts.state.totalSecByKey.get(key) ?? 0; + const clusterTotal = opts.state.totalSecByClusterKey.get(clusterKey) ?? 0; + return ( + total + opts.duration <= maxTotalAssetSec(opts.durationSec, reusableAssets(opts.assets).length) && + clusterTotal + opts.duration <= maxTotalClusterSec(opts.durationSec) + ); + }); + const storyMatched = opts.storyFunction + ? candidates.filter((asset) => assetMatchesStoryFunction(asset, opts.storyFunction!)) + : []; + const storySafe = opts.storyFunction + ? candidates.filter((asset) => !isReservedForOtherKeyStory(asset, opts.storyFunction!)) + : candidates; + const pool = storyMatched.length ? storyMatched : storySafe; + return pool + .sort((a, b) => { + const keyA = `asset:${a.id}`; + const keyB = `asset:${b.id}`; + return ( + (opts.state.totalSecByKey.get(keyA) ?? 0) - (opts.state.totalSecByKey.get(keyB) ?? 0) || + (opts.usageByAsset.get(a.id) ?? 0) - (opts.usageByAsset.get(b.id) ?? 0) + ); + })[0]; +} + +function assetMatchesStoryFunction(asset: TaggedAsset, storyFunction: StoryFunction): boolean { + return (asset.storyRoles ?? []).includes(storyFunction) || asset.narrativeUse === storyFunction; +} + +function isReservedForOtherKeyStory(asset: TaggedAsset, storyFunction: StoryFunction): boolean { + const keys = keyStoryFunctions(); + return (asset.storyRoles ?? []).some((role) => keys.has(role) && role !== storyFunction); +} + +function createRepetitionGuardFill(opts: { + slotId: string; + topic: string; + sellingPoints: string[]; + startSec: number; + endSec: number; + index: number; + reason: RepetitionGuardState['replacements'][number]['reason']; + replacementAsset?: TaggedAsset; +}): FillArtifact { + const point = opts.sellingPoints[(opts.index - 1) % Math.max(1, opts.sellingPoints.length)] ?? opts.topic; + const cue = opts.reason === 'continuous_run' ? '换个节奏看重点' : '补一个信息转场'; + const displayText = sanitizeViewerCopy(`${opts.topic}\n${point}\n${cue}`, { + topic: opts.topic, + sellingPoints: opts.sellingPoints, + assetSummaries: opts.replacementAsset ? [opts.replacementAsset.summary] : [], + }).text; + if (opts.replacementAsset && (opts.replacementAsset.mediaType === 'video' || opts.replacementAsset.mediaType === 'image')) { + return { + id: `fill_guard_${safeId(opts.slotId)}_${opts.index}`, + slotId: opts.slotId, + kind: 'reused_clip', + source: `${REUSED_ASSET_URI}${encodeURIComponent(opts.replacementAsset.id)}`, + displayText, + debugLabel: `素材重复护栏:${opts.slotId}`, + track: 'video', + startSec: opts.startSec, + endSec: opts.endSec, + }; + } + return { + id: `fill_guard_${safeId(opts.slotId)}_${opts.index}`, + slotId: opts.slotId, + kind: 'packaging_overlay', + source: `textcard://guard-card-${opts.index}`, + displayText, + debugLabel: `素材重复护栏:${opts.slotId}`, + track: 'video', + startSec: opts.startSec, + endSec: opts.endSec, + }; +} + +function recordGuardReplacement( + state: RepetitionGuardState, + replacement: RepetitionGuardState['replacements'][number], +): void { + state.replacements.push(replacement); +} + +function recordGuardSource( + state: RepetitionGuardState, + key: string, + clusterKey: string | undefined, + functionKeys: string[], + duration: number, + storyFunction?: StoryFunction, +): void { + if (state.currentKey === key) { + state.currentRunSec += duration; + } else { + state.currentKey = key; + state.currentRunSec = duration; + } + state.totalSecByKey.set(key, (state.totalSecByKey.get(key) ?? 0) + duration); + const effectiveClusterKey = clusterKey ?? key; + if (state.currentClusterKey === effectiveClusterKey) { + state.currentClusterRunSec += duration; + } else { + state.currentClusterKey = effectiveClusterKey; + state.currentClusterRunSec = duration; + } + state.totalSecByClusterKey.set( + effectiveClusterKey, + (state.totalSecByClusterKey.get(effectiveClusterKey) ?? 0) + duration, + ); + functionKeys.forEach((functionKey) => { + state.totalSecByVisualFunctionKey.set( + functionKey, + (state.totalSecByVisualFunctionKey.get(functionKey) ?? 0) + duration, + ); + }); + if (storyFunction && keyStoryFunctions().has(storyFunction)) { + state.storyFunctionAssetByKey.set(storyFunction, key); + state.storyFunctionClusterByKey.set(storyFunction, effectiveClusterKey); + } +} + +function maxContinuousAssetSec(durationSec: number): number { + if (durationSec <= 22) return 1.8; + return Math.min(4.2, Math.max(2.8, durationSec * 0.22)); +} + +function maxTotalAssetSec(durationSec: number, reusableAssetCount: number): number { + const share = durationSec <= 22 ? (reusableAssetCount >= 4 ? 0.28 : 0.35) : 0.4; + return Math.max(durationSec <= 22 ? 2.4 : 3.4, durationSec * share); +} + +function maxContinuousClusterSec(durationSec: number): number { + if (durationSec <= 22) return 2.4; + return Math.min(4.8, Math.max(3.2, durationSec * 0.24)); +} + +function maxTotalClusterSec(durationSec: number): number { + const share = durationSec <= 22 ? 0.35 : 0.42; + return Math.max(durationSec <= 22 ? 3 : 4, durationSec * share); +} + +function visualFunctionRepetitionKeys(asset: TaggedAsset | undefined): string[] { + if (!asset) return []; + const scale = asset.shotScale ?? 'unknown'; + if (scale !== 'close' && scale !== 'macro') return []; + return (asset.visualFunctions ?? []) + .filter((fn) => fn === 'show_detail' || fn === 'show_action' || fn === 'show_progression') + .map((fn) => `vf:${fn}:${scale}`); +} + +function maxTotalVisualFunctionSec(durationSec: number, functionKey: string): number { + const closeDetail = /show_detail|show_action|show_progression/.test(functionKey) && /close|macro/.test(functionKey); + if (closeDetail) return Math.max(durationSec <= 22 ? 6.2 : 7.5, durationSec * (durationSec <= 22 ? 0.48 : 0.5)); + return Math.max(durationSec <= 22 ? 5 : 7, durationSec * 0.55); +} + +function resolveTargetDurationSec( + input: RuleBasedMigrationInput, + selectedPattern: LearnedSamplePattern | undefined, + targetShotSec: number, + templateProfile?: TemplateProfile, +): number { + if (input.durationSec != null) return clampDuration(input.durationSec, 6, 60); + + if (templateProfile && isCarouselTemplateProfile(templateProfile)) { + const explicitBgm = explicitBgmAsset(input.assets); + if (explicitBgm) { + const visualAssetCount = Math.max(1, reusableAssets(input.assets).length); + const visualDriven = Math.max(8, Math.min(15, visualAssetCount * 1.1)); + const audioDriven = explicitBgm.durationSec ? Math.min(explicitBgm.durationSec, visualDriven) : visualDriven; + return clampDuration(audioDriven, 6, 20); + } + return clampDuration(templateProfile.durationSec, 4.8, 8); + } + + const segmentCount = Math.max(1, input.blueprint.scriptStructure.segments.length); + const visualBudgetSec = reusableAssets(input.assets).reduce((sum, asset) => { + if (asset.mediaType === 'image') return sum + 3.2; + return sum + (asset.durationSec ?? 4); + }, 0); + const structuralMin = Math.max(8, Math.min(16, segmentCount * 2.8)); + const structuralMax = Math.max( + structuralMin, + Math.min(28, segmentCount * Math.max(2.2, targetShotSec) * 1.55), + ); + + let target = selectedPattern?.pacing.durationSec + ? Math.max(structuralMin, Math.min(structuralMax, selectedPattern.pacing.durationSec * 1.25)) + : Math.min(22, structuralMax); + + if (visualBudgetSec > 0) { + target = Math.min(target, Math.max(structuralMin, visualBudgetSec * 0.72)); + } + + return clampDuration(target, structuralMin, 28); +} + +function clampDuration(value: number, min: number, max: number): number { + return round(Math.max(min, Math.min(max, value))); +} + +function explicitBgmAsset(assets: TaggedAsset[]): TaggedAsset | undefined { + return assets.find( + (asset) => + asset.isBgm === true && + (asset.mediaType === 'audio' || (asset.mediaType === 'video' && asset.hasAudio !== false)), + ); +} + +function shotNeedsCopy(shot: DirectorPlanShot): boolean { + return shot.copyRequired && shot.copyMode !== 'none'; +} + +function emptyCopyFields(): CopyFields { + return { voiceoverScript: '', screenText: '', cardCopy: '' }; +} + +function copyFieldsForShot( + shot: DirectorPlanShot, + topic: string, + sellingPoints: string[], + copyPattern: string, +): CopyFields { + if (!shotNeedsCopy(shot)) return emptyCopyFields(); + const text = + shot.segmentRole === 'hook' && shot.copyMode === 'screen_text' + ? shot.screenTextIntent + : scriptText(shot.segmentRole, topic, sellingPoints, copyPattern); + const safeText = sanitizeViewerCopy(text, { topic, sellingPoints }).text; + if (!safeText.trim()) return emptyCopyFields(); + + if (shot.copyMode === 'voiceover') { + return { ...emptyCopyFields(), voiceoverScript: safeText }; + } + if (shot.copyMode === 'title_card') { + return { ...emptyCopyFields(), cardCopy: safeText }; + } + return { ...emptyCopyFields(), screenText: safeText }; +} + +function mergeCopyFields(fields: CopyFields[]): CopyFields { + return fields.reduce( + (acc, field) => ({ + voiceoverScript: joinCopy(acc.voiceoverScript, field.voiceoverScript), + screenText: joinCopy(acc.screenText, field.screenText), + cardCopy: joinCopy(acc.cardCopy, field.cardCopy), + }), + emptyCopyFields(), + ); +} + +function displayCopy(fields: CopyFields): string { + return fields.cardCopy || fields.screenText || fields.voiceoverScript || ''; +} + +function joinCopy(a: string, b: string): string { + const next = b.trim(); + if (!next) return a; + return a ? `${a} ${next}` : next; +} + +export function rankLearnedPatterns( + patterns: LearnedSamplePattern[], + blueprint: VideoStructureBlueprint, + opts: { + topic?: string; + sellingPoints?: string[]; + purpose?: 'story' | 'editing'; + } = {}, +): LearnedSamplePattern[] { + const segmentRoles = new Set(blueprint.scriptStructure.segments.map((s) => s.role)); + const slotTags = new Set(blueprint.slots.flatMap((s) => s.requiredAssetTypes)); + const purpose = opts.purpose ?? 'story'; + const commercialIntent = hasCommercialTransferIntent(blueprint, opts.topic, opts.sellingPoints ?? []); + return [...patterns] + .map((pattern) => { + const effectivePattern = withInferredLearningQualityTags(pattern); + const patternRoles = new Set(effectivePattern.segments.map((s) => s.role)); + const patternSlotTags = new Set(effectivePattern.slotNeeds.flatMap((s) => s.requiredAssetTypes)); + const score = + (effectivePattern.videoGenre === blueprint.videoGenre ? 4 : 0) + + (effectivePattern.pacing.cutDensity === blueprint.rhythmStructure.cutDensity ? 1.5 : 0) + + overlapCount(segmentRoles, patternRoles) * 0.6 + + overlapCount(slotTags, patternSlotTags) * 0.45 + + Math.min(1.5, effectivePattern.source.shotCount / 6) + + learnedPatternQualityScore(effectivePattern, purpose, commercialIntent); + return { pattern: effectivePattern, score }; + }) + .sort((a, b) => b.score - a.score || b.pattern.updatedAt.localeCompare(a.pattern.updatedAt)) + .map((r) => r.pattern); +} + +function learnedPatternQualityScore( + pattern: LearnedSamplePattern, + purpose: 'story' | 'editing', + commercialIntent: boolean, +): number { + const tags = pattern.qualityTags; + if (purpose === 'editing') { + return ( + (pattern.editingTechniques.length ? Math.min(2, pattern.editingTechniques.length * 0.7) : -1) + + (pattern.templateProfile ? 1 : 0) + + (tags.recommendedUse === 'editing_only' || tags.recommendedUse === 'template_only' ? 1.2 : 0) + + (tags.patternDepth === 'template_or_editing_only' ? 0.5 : 0) + + (commercialIntent && tags.commercialUsefulness === 'not_recommended' ? -0.8 : 0) + ); + } + + const depthScore: Record = { + full_story: 1.6, + story_candidate: 0.6, + thin_pattern: -1.2, + template_or_editing_only: -4.5, + }; + const recommendedScore: Record = { + primary_story: 2.4, + secondary_story: 0.5, + editing_only: -3.5, + template_only: -4, + learn_only: -4.5, + }; + const commercialScore: Record = { + strong: commercialIntent ? 3 : 0.8, + medium: commercialIntent ? 1 : 0.4, + weak: commercialIntent ? -2.6 : -0.2, + not_recommended: commercialIntent ? -5 : -1.5, + }; + const platformCtaPenalty = + commercialIntent && + (tags.ctaType === 'platform_follow' || tags.ctaType === 'search_account' || tags.ctaType === 'tutorial_get') + ? -2.2 + : 0; + const conversionCtaBonus = + commercialIntent && + (tags.ctaType === 'purchase' || tags.ctaType === 'booking' || tags.ctaType === 'trial' || tags.ctaType === 'lead_capture') + ? 1.2 + : 0; + return ( + depthScore[tags.patternDepth] + + recommendedScore[tags.recommendedUse] + + commercialScore[tags.commercialUsefulness] + + platformCtaPenalty + + conversionCtaBonus + + (pattern.storySkeleton ? 0 : -2.4) + ); +} + +function hasCommercialTransferIntent( + blueprint: VideoStructureBlueprint, + topic: string | undefined, + sellingPoints: string[], +): boolean { + const text = [topic, ...sellingPoints].filter(Boolean).join(' ').toLowerCase(); + if (blueprint.videoGenre === 'product') return true; + if (sellingPoints.length > 0) return true; + return /(产品|商品|店铺|门店|咖啡|餐厅|民宿|美甲|健身|课程|app|saas|软件|工具|购买|下单|预约|试用|转化|lead|booking|trial|buy|shop|product|store|restaurant)/i.test(text); +} + +function targetShotSecFor( + blueprint: VideoStructureBlueprint, + selectedPattern: LearnedSamplePattern | undefined, +): number { + const density = fasterDensity(selectedPattern?.pacing.cutDensity, blueprint.rhythmStructure.cutDensity); + const fallback = density === 'high' ? 2.2 : density === 'medium' ? 2.8 : 4; + const patternSec = selectedPattern?.pacing.avgShotSec; + const blueprintSec = blueprint.rhythmStructure.avgShotSec; + const raw = patternSec && blueprintSec ? Math.min(patternSec, blueprintSec) : patternSec ?? blueprintSec ?? fallback; + const max = density === 'high' ? 2.6 : density === 'medium' ? 3.4 : 5; + return Math.max(0.9, Math.min(max, raw)); +} + +function buildBeatGrid(opts: { + durationSec: number; + cutDensity: string; + blueprint: VideoStructureBlueprint; + selectedPattern?: LearnedSamplePattern; + detected?: BeatGrid; +}): BeatGrid { + if (opts.detected?.beatsSec.length) { + const beatsSec = extendBeatsToDuration(opts.detected.beatsSec, opts.durationSec, opts.detected.bpm); + return BeatGrid.parse({ + ...opts.detected, + beatsSec, + source: 'detected', + confidence: opts.detected.confidence === 'low' ? 'medium' : opts.detected.confidence, + }); + } + const hintCount = + opts.blueprint.rhythmStructure.bgmBeatHints.length + + (opts.selectedPattern?.learnedDimensions.bgmSync.beatHints.length ?? 0); + const bpm = opts.cutDensity === 'high' ? 128 : opts.cutDensity === 'medium' ? 112 : 92; + const step = 60 / bpm; + const beatsSec: number[] = []; + for (let t = 0; t <= opts.durationSec + step; t += step) { + beatsSec.push(round(Math.min(opts.durationSec, t))); + } + return BeatGrid.parse({ + bpm, + offsetSec: 0, + beatsSec: Array.from(new Set(beatsSec)), + source: hintCount ? 'sample_hint' : 'estimated', + confidence: hintCount ? 'medium' : 'low', + rationale: hintCount + ? '根据样例 / 样例库 BGM 提示和 cutDensity 估算 beat grid;尚未接入真实音频 beat detect。' + : '根据 cutDensity 估算 beat grid;尚未接入真实音频 beat detect。', + }); +} + +function extendBeatsToDuration(beats: number[], durationSec: number, bpm: number): number[] { + const filtered = beats.filter((beat) => beat >= 0 && beat <= durationSec).sort((a, b) => a - b); + const intervals = filtered.slice(1).map((beat, i) => beat - filtered[i]).filter((interval) => interval > 0.05); + const step = intervals.length + ? intervals.sort((a, b) => a - b)[Math.floor(intervals.length / 2)] + : 60 / bpm; + const extended = [...filtered]; + let cursor = extended.at(-1) ?? 0; + while (cursor + step <= durationSec + step * 0.5) { + cursor = round(Math.min(durationSec, cursor + step)); + extended.push(cursor); + if (cursor >= durationSec) break; + } + return Array.from(new Set(extended)).filter((beat) => beat >= 0 && beat <= durationSec); +} + +function buildBeatSnappedShotSpans( + shots: DirectorPlanShot[], + durationSec: number, + beatGrid: BeatGrid, + rhythmPlan?: RhythmAlignmentPlan, +): Map { + const spans = new Map(); + let cursor = 0; + const cutAnchors = rhythmCutAnchors(rhythmPlan); + const boundaryCount = Math.max(0, shots.length - 1); + const minShotSec = minShotDurationFor(durationSec, shots.length); + const pacingDurations = desiredShotDurationsFromPacing(shots, durationSec, rhythmPlan?.pacingEnvelope); + shots.forEach((shot, index) => { + const isLast = index === shots.length - 1; + const desiredDur = Math.max(minShotSec, pacingDurations[index] ?? shot.endSec - shot.startSec); + const remainingAfter = Math.max(0, shots.length - index - 1); + const latestEnd = Math.max(cursor + minShotSec, durationSec - remainingAfter * minShotSec); + const minEnd = Math.min(latestEnd, cursor + minShotSec); + const maxEnd = Math.min(latestEnd, cursor + Math.max(minShotSec * 1.6, desiredDur + 0.45)); + const endSec = isLast + ? durationSec + : snapTimeToRhythm(cursor + desiredDur, beatGrid, cutAnchors, { + minSec: minEnd, + maxSec: maxEnd, + durationSec, + boundaryIndex: index, + boundaryCount, + }); + spans.set(shot.shotId, { startSec: round(cursor), endSec: round(endSec) }); + cursor = endSec; + }); + return spans; +} + +function desiredShotDurationsFromPacing( + shots: DirectorPlanShot[], + durationSec: number, + pacingEnvelope?: PacingEnvelope, +): number[] { + const fallback = shots.map((shot) => Math.max(0.1, shot.endSec - shot.startSec)); + if (!pacingEnvelope?.phases.length) return normalizeDesiredDurations(fallback, durationSec); + const raw = shots.map((_, index) => { + const centerRatio = (index + 0.5) / Math.max(1, shots.length); + const phase = + pacingEnvelope.phases.find((p) => centerRatio >= p.startRatio && centerRatio < p.endRatio) ?? + pacingEnvelope.phases.at(-1); + return Math.max(0.1, phase?.avgShotSec ?? fallback[index] ?? 1); + }); + return normalizeDesiredDurations(raw, durationSec); +} + +function normalizeDesiredDurations(rawDurations: number[], durationSec: number): number[] { + if (!rawDurations.length) return []; + const safe = rawDurations.map((duration) => Math.max(0.1, Number.isFinite(duration) ? duration : 1)); + const total = safe.reduce((sum, duration) => sum + duration, 0); + if (total <= 0) return safe.map(() => round(durationSec / safe.length)); + return safe.map((duration) => round((duration / total) * durationSec)); +} + +function minShotDurationFor(durationSec: number, shotCount: number): number { + if (shotCount <= 0) return 0.8; + const averageSec = durationSec / shotCount; + return round(Math.max(0.35, Math.min(0.8, averageSec * 0.65))); +} + +function snapTimeToRhythm( + timeSec: number, + beatGrid: BeatGrid, + cutAnchors: number[], + opts: { minSec: number; maxSec: number; durationSec: number; boundaryIndex: number; boundaryCount: number }, +): number { + const anchors = cutAnchors.filter((anchor) => anchor >= opts.minSec && anchor <= opts.maxSec); + if (anchors.length) { + const expected = opts.boundaryCount > 0 + ? ((opts.boundaryIndex + 1) / (opts.boundaryCount + 1)) * opts.durationSec + : timeSec; + return round( + anchors.reduce((best, anchor) => { + const bestScore = Math.abs(best - timeSec) * 0.65 + Math.abs(best - expected) * 0.35; + const score = Math.abs(anchor - timeSec) * 0.65 + Math.abs(anchor - expected) * 0.35; + return score < bestScore ? anchor : best; + }, anchors[0]), + ); + } + return snapTimeToBeat(timeSec, beatGrid, opts); +} + +function snapTimeToBeat(timeSec: number, beatGrid: BeatGrid, opts: { minSec: number; maxSec: number }): number { + const candidates = beatGrid.beatsSec.filter((beat) => beat >= opts.minSec && beat <= opts.maxSec); + if (!candidates.length) return round(Math.max(opts.minSec, Math.min(opts.maxSec, timeSec))); + return round( + candidates.reduce((best, beat) => (Math.abs(beat - timeSec) < Math.abs(best - timeSec) ? beat : best), candidates[0]), + ); +} + +function fasterDensity(a: string | undefined, b: string): string { + const rank: Record = { low: 1, medium: 2, high: 3 }; + return (rank[a ?? ''] ?? 0) > (rank[b] ?? 0) ? a! : b; +} + +function makeShotSlices( + startSec: number, + endSec: number, + targetShotSec: number, + density: string, +): Array<{ startSec: number; endSec: number }> { + const duration = Math.max(0.001, endSec - startSec); + const maxPerSegment = density === 'high' ? 5 : density === 'medium' ? 4 : 3; + const count = Math.max(1, Math.min(maxPerSegment, Math.round(duration / targetShotSec))); + return Array.from({ length: count }, (_, i) => ({ + startSec: round(startSec + (duration * i) / count), + endSec: i === count - 1 ? endSec : round(startSec + (duration * (i + 1)) / count), + })); +} + +function sourceWindowFor( + asset: TaggedAsset | undefined, + startSec: number, + endSec: number, + cursorByAsset: SourceWindowCursor, +): { sourceInSec: number; sourceOutSec: number } | undefined { + if (!asset || asset.mediaType !== 'video' || !asset.durationSec) return undefined; + const itemDur = Math.max(0.1, endSec - startSec); + const highlight = highlightWindowFor(asset, itemDur, cursorByAsset); + if (highlight) { + const sourceInSec = round(highlight.startSec); + const sourceOutSec = Math.max(round(Math.min(asset.durationSec, highlight.startSec + itemDur)), round(sourceInSec + 0.1)); + cursorByAsset.set(asset.id, sourceOutSec); + return { sourceInSec, sourceOutSec }; + } + const current = Math.min(cursorByAsset.get(asset.id) ?? 0, asset.durationSec); + let sourceInSec = current; + let sourceOutSec = Math.min(current + itemDur, asset.durationSec); + + if (sourceOutSec - sourceInSec < itemDur * 0.8) { + sourceInSec = 0; + sourceOutSec = Math.min(itemDur, asset.durationSec); + } + + sourceInSec = round(sourceInSec); + sourceOutSec = Math.max(round(sourceOutSec), round(sourceInSec + 0.1)); + cursorByAsset.set(asset.id, sourceOutSec); + return { sourceInSec, sourceOutSec }; +} + +function sourceWindowForReferenceAsset( + asset: ReferenceAsset | undefined, + startSec: number, + endSec: number, + timelineDurationSec: number, + cursorByAsset: SourceWindowCursor, +): { sourceInSec: number; sourceOutSec: number } | undefined { + if (!asset || asset.mediaType !== 'video' || !asset.durationSec) return undefined; + const itemDur = Math.max(0.1, endSec - startSec); + const maxStart = Math.max(0, asset.durationSec - itemDur); + const safeWindow = referenceSafeSourceWindow(asset, itemDur, maxStart); + const highlight = highlightWindowForReferenceAsset( + asset, + itemDur, + cursorByAsset, + safeWindow.safeMinStart, + safeWindow.safeMaxStart, + ); + if (highlight) { + const sourceInSec = round(Math.max(safeWindow.safeMinStart, Math.min(safeWindow.safeMaxStart, highlight.startSec))); + const sourceOutSec = Math.max(round(Math.min(asset.durationSec, sourceInSec + itemDur)), round(sourceInSec + 0.1)); + cursorByAsset.set(asset.id, sourceOutSec); + return { sourceInSec, sourceOutSec }; + } + + const timelineWindow = Math.max(0.1, timelineDurationSec - itemDur); + const ratio = Math.min(1, Math.max(0, startSec / timelineWindow)); + const sourceInSec = snapReferenceSourceBoundary( + Math.max(safeWindow.safeMinStart, Math.min(safeWindow.safeMaxStart, maxStart * ratio)), + safeWindow.safeMinStart, + safeWindow.safeMaxStart, + ); + const sourceOutSec = Math.max(round(Math.min(asset.durationSec, sourceInSec + itemDur)), round(sourceInSec + 0.1)); + cursorByAsset.set(asset.id, sourceOutSec); + return { sourceInSec, sourceOutSec }; +} + +function referenceSafeSourceWindow( + asset: ReferenceAsset, + itemDur: number, + maxStart: number, +): { safeMinStart: number; safeMaxStart: number } { + if (maxStart <= 0.05) return { safeMinStart: 0, safeMaxStart: 0 }; + const durationSec = asset.durationSec ?? itemDur; + const text = normalizedAssetText(asset); + const textLikeBoundary = /(片头|片尾|标题|大字|字幕|字母|文字|logo|水印|title|caption|text|letter|lyric|opening|intro|outro|endcard|end card)/i.test(text); + const platformLike = REFERENCE_PLATFORM_TEXT.test(text); + const openingSkip = textLikeBoundary ? REFERENCE_TEXTLIKE_SAFE_SKIP_SEC : REFERENCE_OPENING_SAFE_SKIP_SEC; + const outroSkip = textLikeBoundary || platformLike ? REFERENCE_TEXTLIKE_OUTRO_SAFE_SKIP_SEC : REFERENCE_OUTRO_SAFE_SKIP_SEC; + const safeMaxStart = Math.min(maxStart, Math.max(0, durationSec - itemDur - outroSkip)); + let safeMinStart = Math.min(maxStart, openingSkip); + if (safeMaxStart < safeMinStart) { + safeMinStart = safeMaxStart; + } + return { + safeMinStart: round(safeMinStart), + safeMaxStart: round(safeMaxStart), + }; +} + +function snapReferenceSourceBoundary(sec: number, safeMinStart: number, safeMaxStart: number): number { + if (safeMaxStart <= safeMinStart) return round(Math.max(0, safeMaxStart)); + const snapped = Math.round(sec / REFERENCE_SOURCE_SNAP_SEC) * REFERENCE_SOURCE_SNAP_SEC; + return round(Math.max(safeMinStart, Math.min(safeMaxStart, snapped))); +} + +function highlightWindowFor( + asset: TaggedAsset, + itemDur: number, + cursorByAsset: SourceWindowCursor, +): { startSec: number; endSec: number } | undefined { + const current = cursorByAsset.get(asset.id) ?? 0; + const windows = (asset.highlightWindows ?? []) + .filter((window) => window.endSec - window.startSec >= Math.min(0.8, itemDur * 0.5)) + .filter((window) => window.endSec > current + 0.2) + .sort((a, b) => b.score - a.score || a.startSec - b.startSec); + return windows[0]; +} + +function highlightWindowForReferenceAsset( + asset: ReferenceAsset, + itemDur: number, + cursorByAsset: SourceWindowCursor, + safeMinStart: number, + safeMaxStart: number, +): { startSec: number; endSec: number } | undefined { + const current = Math.max(cursorByAsset.get(asset.id) ?? safeMinStart, safeMinStart); + const windows = (asset.highlightWindows ?? []) + .filter((window) => window.endSec - window.startSec >= Math.min(0.8, itemDur * 0.5)) + .filter((window) => window.startSec >= safeMinStart - 0.05) + .filter((window) => window.startSec <= safeMaxStart + 0.05) + .filter((window) => window.endSec > current + 0.2) + .sort((a, b) => { + const stableA = referenceHighlightStabilityScore(a.reason); + const stableB = referenceHighlightStabilityScore(b.reason); + return stableB - stableA || b.score - a.score || a.startSec - b.startSec; + }); + return windows[0]; +} + +function referenceHighlightStabilityScore(reason: string | undefined): number { + if (!reason) return 0; + let score = 0; + if (/稳定|风景|远景|环境|尺度|landscape|scenic|wide|stable|establish/i.test(reason)) score += 1; + if (/标题|大字|字幕|文字|letter|title|caption|text|intro/i.test(reason)) score -= 1.5; + return score; +} + +function motionPresetFor(density: string, index: number, track: TimelineItem['track']): MotionPreset { + if (track !== 'video') return 'static'; + void density; + void index; + return 'static'; +} + +function transitionPresetFor(density: string, index: number): TransitionPreset { + if (index <= 1) return 'cut'; + void density; + return index % 5 === 0 ? 'crossfade' : 'cut'; +} + +function transitionPresetForCard(animation: CardAnimationPreset): TransitionPreset { + if (animation === 'soft_crossfade' || animation === 'fade_push' || animation === 'wipe_up') return 'crossfade'; + return 'cut'; +} + +function stableMotionPresetFor(opts: { + track: TimelineItem['track']; + asset: TaggedAsset | undefined; + isGeneratedCard: boolean; + requested: MotionPreset; + segmentRole: z.infer; + itemDurationSec: number; + previousImageMotion?: MotionPreset; + visualIndex: number; + lowMaterialExpansion?: LowMaterialExpansionPlan; +}): MotionPreset { + if (opts.track !== 'video') return 'static'; + if (opts.isGeneratedCard) return 'static'; + if (opts.asset?.mediaType === 'video') { + if (opts.segmentRole === 'hook' && opts.itemDurationSec <= 2.2) return 'push_in'; + return opts.lowMaterialExpansion?.enabled && opts.lowMaterialExpansion.allowVideoMotion + ? lowMaterialVideoMotionPreset(opts.requested, opts.visualIndex) + : 'static'; + } + if (opts.asset?.mediaType === 'image') { + return diversifyImageMotion(opts.requested, opts.previousImageMotion, opts.visualIndex); + } + return 'static'; +} + +function lowMaterialVideoMotionPreset(requested: MotionPreset, index: number): MotionPreset { + const safe: MotionPreset[] = ['static', 'ken_burns_in', 'push_in', 'push_out']; + if (requested !== 'static' && safe.includes(requested)) return requested; + return safe[index % safe.length]; +} + +function lowMaterialCropPresetFor( + base: StandardCropPreset | undefined, + asset: TaggedAsset | undefined, + index: number, + lowMaterialExpansion: LowMaterialExpansionPlan, +): StandardCropPreset | undefined { + if (!lowMaterialExpansion.enabled || asset?.mediaType !== 'video') return base; + const portrait = (aspectRatioNumber(asset.aspectRatio) || 0) < 0.9; + const presets: StandardCropPreset[] = portrait + ? ['center', 'closeup', 'top', 'bottom', 'center', 'closeup'] + : ['center', 'closeup', 'left', 'right', 'center', 'top']; + return presets[Math.max(0, index - 1) % presets.length] ?? base; +} + +function diversifyImageMotion( + requested: MotionPreset, + previous: MotionPreset | undefined, + index: number, +): MotionPreset { + const executable: MotionPreset[] = [ + 'push_in', + 'push_out', + 'pan_left', + 'pan_right', + 'pan_up', + 'pan_down', + 'snap_zoom', + 'ken_burns_in', + 'parallax_drift', + 'tilt_in', + 'beat_pulse', + 'reveal_pan', + ]; + const fallback = executable[index % executable.length]; + const candidate = requested === 'static' ? fallback : requested; + if (candidate !== previous) return candidate; + return executable.find((preset) => preset !== previous) ?? candidate; +} + +function stableTransitionPresetFor(opts: { + track: TimelineItem['track']; + asset: TaggedAsset | undefined; + isGeneratedCard: boolean; + requested: TransitionPreset; +}): TransitionPreset { + if (opts.isGeneratedCard) return opts.requested === 'crossfade' ? 'crossfade' : 'cut'; + if (opts.track !== 'video') return 'cut'; + if (opts.asset?.mediaType === 'video') return 'cut'; + if (opts.asset?.mediaType === 'image') return opts.requested; + if (opts.requested === 'crossfade') return 'crossfade'; + return 'cut'; +} + +function cropPresetFor(index: number): StandardCropPreset { + const presets: StandardCropPreset[] = ['center', 'closeup', 'left', 'right', 'top']; + return presets[index % presets.length]; +} + +function assetFromFill(fill: FillArtifact, assets: TaggedAsset[]): TaggedAsset | undefined { + if (!fill.source.startsWith(REUSED_ASSET_URI)) return undefined; + const assetId = decodeURIComponent(fill.source.slice(REUSED_ASSET_URI.length)); + return assets.find((asset) => asset.id === assetId); +} + +function referenceAssetFromFill(fill: FillArtifact, referenceAssets: ReferenceAsset[]): ReferenceAsset | undefined { + if (fill.kind !== 'reference_clip') return undefined; + return referenceAssets.find((asset) => asset.sourcePath === fill.source); +} + +function isGeneratedCardSource(source: TimelineSource, fills: FillArtifact[]): boolean { + if (source.kind !== 'fill_artifact') return false; + const fill = fills.find((f) => f.id === source.fillArtifactId); + return Boolean(fill?.source.startsWith('textcard://')); +} + +function applyReferenceClipBudgetGuard(opts: { + items: TimelineItem[]; + fills: FillArtifact[]; + assets: TaggedAsset[]; + referenceAssets: ReferenceAsset[]; + visualGapPolicy: VisualGapPolicy; + durationSec: number; + topic: string; + sellingPoints: string[]; +}): { + changed: boolean; + items: TimelineItem[]; + evidence: Evidence[]; + decisions: Decision[]; +} { + const referenceItems = opts.items + .filter((item) => item.track !== 'audio' && referenceFillForItem(item, opts.fills)) + .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); + if (!referenceItems.length) { + return { changed: false, items: opts.items, evidence: [], decisions: [] }; + } + + const maxReferenceSec = round(opts.durationSec * opts.visualGapPolicy.maxReferenceShare); + const usedSecByAsset = timelineUserAssetSecondsFromItems(opts.items, opts.fills, opts.assets); + const sourceCursor = seedSourceWindowCursorFromItems(opts.items); + const referenceCursor: SourceWindowCursor = new Map(); + const seenReferenceSources = new Set(); + const revisedByItemId = new Map(); + const expandedStartByItemId = new Map(); + const notes: string[] = []; + let usedReferenceSec = 0; + let replacementIndex = 0; + + for (const item of referenceItems) { + const fill = referenceFillForItem(item, opts.fills); + if (!fill) continue; + const sourceKey = fill.source || fill.id; + const itemDurationSec = Math.max(0, item.endSec - item.startSec); + const remainingBudgetSec = round(maxReferenceSec - usedReferenceSec); + const duplicateSource = seenReferenceSources.has(sourceKey); + const shouldReplaceWhole = + duplicateSource || + remainingBudgetSec < MIN_REFERENCE_RETAIN_SEC || + opts.visualGapPolicy.referenceClipUse !== 'bridge_only'; + + if (shouldReplaceWhole) { + replacementIndex += 1; + const replacement = createReferenceGuardReplacementItem({ + item, + startSec: item.startSec, + endSec: item.endSec, + fills: opts.fills, + assets: opts.assets, + usedSecByAsset, + sourceCursor, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + durationSec: opts.durationSec, + index: replacementIndex, + reason: duplicateSource ? 'duplicate_source' : 'budget_exceeded', + }); + revisedByItemId.set(item.id, [replacement]); + notes.push(`${item.id}->${fallbackSourceKey(replacement.source)}:${duplicateSource ? 'same_source' : 'over_budget'}`); + continue; + } + + const keepDurationSec = round(Math.min(itemDurationSec, opts.visualGapPolicy.maxReferenceClipSec, remainingBudgetSec)); + if (keepDurationSec < MIN_REFERENCE_RETAIN_SEC) { + replacementIndex += 1; + const replacement = createReferenceGuardReplacementItem({ + item, + startSec: item.startSec, + endSec: item.endSec, + fills: opts.fills, + assets: opts.assets, + usedSecByAsset, + sourceCursor, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + durationSec: opts.durationSec, + index: replacementIndex, + reason: 'budget_exceeded', + }); + revisedByItemId.set(item.id, [replacement]); + notes.push(`${item.id}->${fallbackSourceKey(replacement.source)}:below_min_budget`); + continue; + } + + const keepEndSec = round(item.startSec + keepDurationSec); + const referenceAsset = referenceAssetFromFill(fill, opts.referenceAssets); + const sourceWindow = sourceWindowForReferenceAsset( + referenceAsset, + item.startSec, + keepEndSec, + opts.durationSec, + referenceCursor, + ); + const keptItem: TimelineItem = { + ...item, + endSec: keepEndSec, + sourceInSec: sourceWindow?.sourceInSec, + sourceOutSec: sourceWindow?.sourceOutSec, + motionPreset: 'static', + cropPreset: 'contain', + cardStylePreset: undefined, + cardAnimationPreset: undefined, + }; + const replacementItems = [keptItem]; + usedReferenceSec = round(usedReferenceSec + keepDurationSec); + seenReferenceSources.add(sourceKey); + + if (item.endSec - keepEndSec >= 0.1) { + const adjacentRemainder = findAdjacentReferenceRemainderItem(opts.items, opts.fills, item); + if (adjacentRemainder) { + expandedStartByItemId.set(adjacentRemainder.id, keepEndSec); + notes.push(`${item.id}:trim_${round(itemDurationSec)}s_to_${keepDurationSec}s_expand_${adjacentRemainder.id}`); + } else { + replacementIndex += 1; + const replacement = createReferenceGuardReplacementItem({ + item: { + ...item, + id: `${item.id}_ref_guard_remainder`, + }, + startSec: keepEndSec, + endSec: item.endSec, + fills: opts.fills, + assets: opts.assets, + usedSecByAsset, + sourceCursor, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + durationSec: opts.durationSec, + index: replacementIndex, + reason: 'budget_exceeded', + }); + replacementItems.push(replacement); + notes.push(`${item.id}:trim_${round(itemDurationSec)}s_to_${keepDurationSec}s`); + } + } else { + notes.push(`${item.id}:keep_${keepDurationSec}s`); + } + revisedByItemId.set(item.id, replacementItems); + } + + if (!revisedByItemId.size) { + return { changed: false, items: opts.items, evidence: [], decisions: [] }; + } + + const revisedItems = opts.items.flatMap((item) => { + const replacement = revisedByItemId.get(item.id); + if (replacement) return replacement; + const expandedStartSec = expandedStartByItemId.get(item.id); + if (expandedStartSec == null) return [item]; + return [ + { + ...item, + startSec: expandedStartSec, + }, + ]; + }); + const referenceSec = referenceClipTimelineSeconds(revisedItems, opts.fills); + return { + changed: true, + items: revisedItems, + evidence: [ + { + type: 'reference_clip_budget_guard', + detail: `Reference clip 时间线预算 ${maxReferenceSec}s(目标 <= ${(opts.visualGapPolicy.maxReferenceShare * 100).toFixed(0)}%);实际 ${referenceSec}s。已限制同一 reference source 只保留一次,并把超预算 / 重复小段替换为用户实景素材或包装卡。`, + ref: notes.join(', '), + }, + ], + decisions: [ + { + chosen: 'reference_bridge_budget_and_safe_window_guard', + alternatives: ['keep_all_reference_slices', 'disable_reference_bridge', 'rerun_director_plan'], + confidence: 0.82, + reason: `reference_bridge 只应短暂借用样例的氛围 / 尺度 / 转场语言;时间线强制总占比不超过约 ${Math.round(opts.visualGapPolicy.maxReferenceShare * 100)}%,同源不连续拆碎,并为保留片段避开片头标题 / 大字 / 字幕动画。`, + }, + ], + }; +} + +function findAdjacentReferenceRemainderItem( + items: TimelineItem[], + fills: FillArtifact[], + referenceItem: TimelineItem, +): TimelineItem | undefined { + const visualItems = items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec || a.endSec - b.endSec); + const index = visualItems.findIndex((item) => item.id === referenceItem.id); + if (index < 0) return undefined; + const next = visualItems[index + 1]; + if (!next) return undefined; + if (Math.abs(next.startSec - referenceItem.endSec) > 0.05) return undefined; + if (referenceItem.shotRef && next.shotRef !== referenceItem.shotRef) return undefined; + if (!/_remainder/.test(next.id)) return undefined; + if (referenceFillForItem(next, fills)) return undefined; + return next; +} + +function referenceFillForItem(item: TimelineItem, fills: FillArtifact[]): FillArtifact | undefined { + const fill = fillForTimelineSource(item.source, fills); + return fill?.kind === 'reference_clip' ? fill : undefined; +} + +function referenceClipTimelineSeconds(items: TimelineItem[], fills: FillArtifact[]): number { + return round( + items.reduce((sum, item) => { + if (!referenceFillForItem(item, fills)) return sum; + return sum + Math.max(0, item.endSec - item.startSec); + }, 0), + ); +} + +function timelineUserAssetSecondsFromItems( + items: TimelineItem[], + fills: FillArtifact[], + assets: TaggedAsset[], +): Map { + const sec = new Map(); + items.forEach((item) => { + let assetId: string | undefined; + if (item.source.kind === 'user_asset') { + assetId = item.source.assetId; + } else if (item.source.kind === 'fill_artifact') { + const fill = fillForTimelineSource(item.source, fills); + assetId = fill ? assetFromFill(fill, assets)?.id : undefined; + } + if (!assetId) return; + sec.set(assetId, (sec.get(assetId) ?? 0) + Math.max(0, item.endSec - item.startSec)); + }); + return sec; +} + +function createReferenceGuardReplacementItem(opts: { + item: TimelineItem; + startSec: number; + endSec: number; + fills: FillArtifact[]; + assets: TaggedAsset[]; + usedSecByAsset: Map; + sourceCursor: SourceWindowCursor; + topic: string; + sellingPoints: string[]; + durationSec: number; + index: number; + reason: 'budget_exceeded' | 'duplicate_source'; +}): TimelineItem { + const duration = Math.max(0.1, opts.endSec - opts.startSec); + const replacementAsset = pickReferenceGuardReplacementAsset( + opts.assets, + opts.usedSecByAsset, + duration, + opts.durationSec, + ); + if (replacementAsset) { + opts.usedSecByAsset.set(replacementAsset.id, (opts.usedSecByAsset.get(replacementAsset.id) ?? 0) + duration); + const sourceWindow = sourceWindowFor(replacementAsset, opts.startSec, opts.endSec, opts.sourceCursor); + return { + ...opts.item, + startSec: opts.startSec, + endSec: opts.endSec, + track: 'video', + source: { kind: 'user_asset', assetId: replacementAsset.id }, + sourceInSec: sourceWindow?.sourceInSec, + sourceOutSec: sourceWindow?.sourceOutSec, + motionPreset: replacementAsset.mediaType === 'image' + ? (opts.item.motionPreset && opts.item.motionPreset !== 'static' ? opts.item.motionPreset : 'ken_burns_in') + : opts.startSec <= 2.4 + ? 'push_in' + : 'static', + cropPreset: replacementAsset.safeCropPreset ?? (opts.item.cropPreset === 'contain' ? 'center' : opts.item.cropPreset), + cardStylePreset: undefined, + cardAnimationPreset: undefined, + }; + } + + const fill = createReferenceGuardPackagingFill({ + slotId: `${opts.item.slotRef ?? opts.item.shotRef ?? opts.item.id}_reference_guard`, + topic: opts.topic, + sellingPoints: opts.sellingPoints, + startSec: opts.startSec, + endSec: opts.endSec, + index: opts.index, + reason: opts.reason, + }); + opts.fills.push(fill); + return { + ...opts.item, + startSec: opts.startSec, + endSec: opts.endSec, + track: 'video', + source: { kind: 'fill_artifact', fillArtifactId: fill.id }, + sourceInSec: undefined, + sourceOutSec: undefined, + motionPreset: 'static', + cropPreset: opts.item.cropPreset === 'contain' ? 'center' : opts.item.cropPreset, + cardStylePreset: 'lifestyle_story', + cardAnimationPreset: 'soft_crossfade', + }; +} + +function pickReferenceGuardReplacementAsset( + assets: TaggedAsset[], + usedSecByAsset: Map, + duration: number, + durationSec: number, +): TaggedAsset | undefined { + const candidates = reusableAssets(assets); + const budgetSafe = candidates.filter((asset) => { + const used = usedSecByAsset.get(asset.id) ?? 0; + return used + duration <= maxTotalAssetSec(durationSec, candidates.length) + 0.2; + }); + const pool = budgetSafe.length ? budgetSafe : candidates; + return pool.sort((a, b) => + referenceGuardReplacementScore(b, usedSecByAsset) - referenceGuardReplacementScore(a, usedSecByAsset), + )[0]; +} + +function referenceGuardReplacementScore(asset: TaggedAsset, usedSecByAsset: Map): number { + const text = normalizedAssetText(asset); + const functions = asset.visualFunctions ?? []; + const scenic = /(风景|湖|水|海|山|天空|森林|瀑布|landscape|lake|water|sea|mountain|sky|forest|scenic)/i.test(text); + let score = asset.confidence + (asset.qualityScore ?? 0.5) * 0.35; + if (asset.shotScale === 'wide') score += 0.5; + if (functions.includes('establish_context')) score += 0.35; + if (functions.includes('show_scale')) score += 0.35; + if (functions.includes('show_result')) score += 0.25; + if (scenic) score += 0.35; + if (asset.mediaType === 'image') score += 0.15; + score -= (usedSecByAsset.get(asset.id) ?? 0) * 0.16; + return score; +} + +function createReferenceGuardPackagingFill(opts: { + slotId: string; + topic: string; + sellingPoints: string[]; + startSec: number; + endSec: number; + index: number; + reason: 'budget_exceeded' | 'duplicate_source'; +}): FillArtifact { + const point = opts.sellingPoints[(opts.index - 1) % Math.max(1, opts.sellingPoints.length)] ?? opts.topic; + const cue = opts.reason === 'duplicate_source' ? '换个画面承接节奏' : '把重点留给真实素材'; + const displayText = sanitizeViewerCopy(`${opts.topic}\n${point}\n${cue}`, { + topic: opts.topic, + sellingPoints: opts.sellingPoints, + }).text; + return { + id: `fill_ref_guard_${safeId(opts.slotId)}_${opts.index}`, + slotId: opts.slotId, + kind: 'packaging_overlay', + source: `textcard://reference-guard-${opts.index}`, + displayText, + debugLabel: `参考画面预算护栏:${opts.slotId}`, + track: 'video', + startSec: opts.startSec, + endSec: opts.endSec, + }; +} + +function applyFullScreenTextCardBudgetRevision(opts: { + timeline: Timeline; + fills: FillArtifact[]; + script: ScriptLine[]; + storyboard: StoryboardItem[]; + directorPlan: DirectorPlan; + assets: TaggedAsset[]; + durationSec: number; +}): { + changed: boolean; + timeline: Timeline; + script: ScriptLine[]; + storyboard: StoryboardItem[]; + evidence: Evidence[]; + decisions: Decision[]; +} { + const backgroundAssets = reusableAssets(opts.assets); + if (!backgroundAssets.length) { + return { changed: false, timeline: opts.timeline, script: opts.script, storyboard: opts.storyboard, evidence: [], decisions: [] }; + } + + const shotById = new Map(opts.directorPlan.shots.map((shot) => [shot.shotId, shot])); + const usedSecByAsset = timelineUserAssetSeconds(opts.timeline, opts.fills, opts.assets); + const maxCardSec = round(Math.max(MIN_FULL_SCREEN_TEXT_CARD_BUDGET_SEC, opts.durationSec * MAX_FULL_SCREEN_TEXT_CARD_SHARE)); + let retainedCardSec = 0; + const replacements = new Map(); + const replacementTextByItemId = new Map(); + const replacementTextByShotId = new Map(); + const replacementTextBySegmentRole = new Map, string>(); + const revisedShotIds = new Set(); + const replacementNotes: string[] = []; + + const visualItems = opts.timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec); + const firstVisualItemId = visualItems[0]?.id; + + for (const item of visualItems) { + const fill = fillForTimelineSource(item.source, opts.fills); + if (!fill?.source.startsWith('textcard://')) continue; + const shot = item.shotRef ? shotById.get(item.shotRef) : undefined; + const duration = Math.max(0, item.endSec - item.startSec); + const isFirstScreen = item.id === firstVisualItemId || item.startSec < 2.2 || shot?.segmentRole === 'hook'; + const preferBackground = shouldPreferBackgroundForTextCard(shot, fill, isFirstScreen); + if (fill.id.startsWith('fill_guard_') || (isFirstScreen && !preferBackground)) { + retainedCardSec += duration; + continue; + } + const overBudget = retainedCardSec + duration > maxCardSec; + if (preferBackground || overBudget) { + const replacement = pickTextCardBackgroundAsset(shot, backgroundAssets, usedSecByAsset, { + item, + itemDurationSec: duration, + timeline: opts.timeline, + timelineDurationSec: opts.durationSec, + }); + if (replacement) { + const overlayText = textForTextCardFill(fill); + replacements.set(item.id, replacement); + replacementTextByItemId.set(item.id, overlayText); + if (item.shotRef) replacementTextByShotId.set(item.shotRef, overlayText); + if (shot) replacementTextBySegmentRole.set(shot.segmentRole, overlayText); + usedSecByAsset.set(replacement.id, (usedSecByAsset.get(replacement.id) ?? 0) + duration); + if (item.shotRef) revisedShotIds.add(item.shotRef); + replacementNotes.push(`${item.id}->${replacement.id}${preferBackground ? ':packaging_overlay' : ':budget'}`); + continue; + } + } + retainedCardSec += duration; + } + + if (!replacements.size) { + return { changed: false, timeline: opts.timeline, script: opts.script, storyboard: opts.storyboard, evidence: [], decisions: [] }; + } + + const sourceCursor = seedSourceWindowCursor(opts.timeline); + const replacementWindowByItemId = new Map>(); + for (const item of [...opts.timeline.items].sort((a, b) => a.startSec - b.startSec)) { + const replacement = replacements.get(item.id); + if (!replacement) continue; + replacementWindowByItemId.set(item.id, sourceWindowFor(replacement, item.startSec, item.endSec, sourceCursor)); + } + const revisedItems = opts.timeline.items.map((item) => { + const replacement = replacements.get(item.id); + if (!replacement) return item; + const sourceWindow = replacementWindowByItemId.get(item.id); + const cropPreset = replacement.safeCropPreset ?? (item.cropPreset === 'contain' ? 'center' : item.cropPreset); + const overlayText = replacementTextByItemId.get(item.id); + return { + ...item, + track: 'video' as const, + source: { kind: 'user_asset' as const, assetId: replacement.id }, + sourceInSec: sourceWindow?.sourceInSec, + sourceOutSec: sourceWindow?.sourceOutSec, + motionPreset: replacement.mediaType === 'image' + ? (item.motionPreset && item.motionPreset !== 'static' ? item.motionPreset : 'ken_burns_in') + : item.startSec <= 2.4 + ? 'push_in' + : 'static', + cropPreset, + framePolicy: 'real_background_lower_third' as const, + overlayText, + }; + }); + + const revisedStoryboard = opts.storyboard.map((item) => { + if (!item.shotId || !revisedShotIds.has(item.shotId)) return item; + const shot = shotById.get(item.shotId); + const fallbackText = shot?.screenTextIntent || shot?.communicationIntent || ''; + const screenText = item.screenText || item.cardCopy || replacementTextByShotId.get(item.shotId) || item.copy || fallbackText; + return { + ...item, + screenText, + cardCopy: '', + copy: item.copy || screenText, + }; + }); + const revisedSegmentRoles = new Set( + opts.directorPlan.shots + .filter((shot) => revisedShotIds.has(shot.shotId)) + .map((shot) => shot.segmentRole), + ); + const revisedScript = opts.script.map((line) => { + if (!revisedSegmentRoles.has(line.segmentRole)) return line; + const screenText = line.screenText || line.cardCopy || replacementTextBySegmentRole.get(line.segmentRole) || line.text; + return { + ...line, + screenText, + cardCopy: '', + text: line.text || screenText, + }; + }); + + const revisedTimeline = Timeline.parse({ ...opts.timeline, items: revisedItems }); + return { + changed: true, + timeline: revisedTimeline, + script: revisedScript, + storyboard: revisedStoryboard, + evidence: [ + { + type: 'text_card_budget', + detail: `全屏文字卡预算 ${maxCardSec}s(目标 <= ${(MAX_FULL_SCREEN_TEXT_CARD_SHARE * 100).toFixed(0)}%);已将 ${replacements.size} 个包装 / closing / payoff 或超预算文字卡改为真实素材背景 + 样例包装文案 overlay。`, + ref: replacementNotes.join(', '), + }, + ], + decisions: [ + { + chosen: 'real_background_with_lower_third_copy', + alternatives: ['keep_fullscreen_text_cards', 'shorten_cards_only', 'aigc_background'], + confidence: 0.78, + reason: `包装文案、closing/payoff 和超预算文字卡会让成片像 PPT;有真实视觉素材时优先保留样例迁移来的文案/样式但换成实景背景,目标把全屏文字卡控制在 ${Math.round(MAX_FULL_SCREEN_TEXT_CARD_SHARE * 100)}% 左右。`, + }, + ], + }; +} + +function shouldPreferBackgroundForTextCard( + shot: DirectorPlanShot | undefined, + fill: FillArtifact, + isFirstScreen: boolean, +): boolean { + if (fill.kind === 'packaging_overlay') return true; + if (fill.kind === 'text_card' && !isFirstScreen) return true; + if (!shot) return false; + return ( + shot.segmentRole === 'closing' || + shot.storyFunction === 'payoff' || + shot.storyFunction === 'cta' || + shot.visualRole === 'cta_card' || + shot.visualRole === 'transition_card' || + (!isFirstScreen && shot.copyMode === 'title_card') + ); +} + +function textForTextCardFill(fill: FillArtifact): string { + const fallback = fill.source.startsWith('textcard://') ? decodeTextCardFillSource(fill.source) : ''; + return (fill.displayText || fallback).replace(/\s+/g, ' ').trim(); +} + +function decodeTextCardFillSource(source: string): string { + const raw = source.slice('textcard://'.length); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + +function pickTextCardBackgroundAsset( + shot: DirectorPlanShot | undefined, + assets: TaggedAsset[], + usedSecByAsset: Map, + opts: { + item: TimelineItem; + itemDurationSec: number; + timeline: Timeline; + timelineDurationSec: number; + }, +): TaggedAsset | undefined { + return [...assets] + .filter((asset) => canUseTextCardBackgroundAsset(asset, shot, usedSecByAsset, opts, assets.length)) + .sort((a, b) => + textCardBackgroundScore(b, shot, usedSecByAsset) - textCardBackgroundScore(a, shot, usedSecByAsset), + )[0]; +} + +function canUseTextCardBackgroundAsset( + asset: TaggedAsset, + shot: DirectorPlanShot | undefined, + usedSecByAsset: Map, + opts: { + item: TimelineItem; + itemDurationSec: number; + timeline: Timeline; + timelineDurationSec: number; + }, + reusableAssetCount: number, +): boolean { + const usedSec = usedSecByAsset.get(asset.id) ?? 0; + const firstCriticalStoryUse = + usedSec <= 0 && + Boolean(shot?.storyFunction && (keyStoryFunctions().has(shot.storyFunction) || shot.storyFunction === 'cta')); + if (!firstCriticalStoryUse && usedSec + opts.itemDurationSec > maxTotalAssetSec(opts.timelineDurationSec, reusableAssetCount)) { + return false; + } + if (adjacentUserAssetRunSec(opts.timeline, opts.item, asset.id) > maxContinuousAssetSec(opts.timelineDurationSec)) { + return false; + } + return true; +} + +function adjacentUserAssetRunSec(timeline: Timeline, item: TimelineItem, assetId: string): number { + const visualItems = timeline.items + .filter((candidate) => candidate.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec); + const index = visualItems.findIndex((candidate) => candidate.id === item.id); + if (index < 0) return Math.max(0, item.endSec - item.startSec); + let runSec = Math.max(0, item.endSec - item.startSec); + let hasAdjacentAsset = false; + for (let i = index - 1; i >= 0; i -= 1) { + const candidate = visualItems[i]; + if (candidate.source.kind !== 'user_asset' || candidate.source.assetId !== assetId) break; + if (Math.abs(candidate.endSec - (i === index - 1 ? item.startSec : visualItems[i + 1].startSec)) > 0.05) break; + hasAdjacentAsset = true; + runSec += Math.max(0, candidate.endSec - candidate.startSec); + } + for (let i = index + 1; i < visualItems.length; i += 1) { + const candidate = visualItems[i]; + if (candidate.source.kind !== 'user_asset' || candidate.source.assetId !== assetId) break; + if (Math.abs(candidate.startSec - (i === index + 1 ? item.endSec : visualItems[i - 1].endSec)) > 0.05) break; + hasAdjacentAsset = true; + runSec += Math.max(0, candidate.endSec - candidate.startSec); + } + return hasAdjacentAsset ? runSec : 0; +} + +function textCardBackgroundScore( + asset: TaggedAsset, + shot: DirectorPlanShot | undefined, + usedSecByAsset: Map, +): number { + const text = normalizedAssetText(asset); + const functions = asset.visualFunctions ?? []; + const roles = asset.storyRoles ?? []; + const scenic = /(风景|湖|水|海|山|天空|森林|瀑布|蓝|绿|landscape|lake|water|sea|mountain|sky|forest|waterfall|scenic)/i.test(text); + let score = asset.confidence + (asset.qualityScore ?? 0.5) * 0.4; + if (asset.mediaType === 'image') score += 0.2; + if (asset.shotScale === 'wide') score += 0.8; + if (functions.includes('establish_context')) score += 0.5; + if (functions.includes('show_scale')) score += 0.5; + if (scenic) score += 0.6; + if (asset.visualClusterId && /(blue_water|waterfall|landscape|sky|mountain|forest)/i.test(asset.visualClusterId)) score += 0.4; + if (shot) { + score += visualAffinityScore(shot, asset) * 0.5; + score += visualFunctionAffinityScore(shot, asset) * 0.45; + if (shot.segmentRole === 'closing' || shot.storyFunction === 'payoff' || shot.storyFunction === 'cta') { + if (functions.includes('show_result') || functions.includes('call_to_action')) score += 1.2; + if (roles.includes('payoff') || roles.includes('cta') || asset.narrativeUse === 'payoff' || asset.narrativeUse === 'cta') score += 1; + if (scenic) score += 0.6; + } + } + if (asset.shotScale === 'close' || asset.shotScale === 'macro') score -= 0.45; + score -= (usedSecByAsset.get(asset.id) ?? 0) * 0.18; + return score; +} + +function timelineUserAssetSeconds(timeline: Timeline, fills: FillArtifact[], assets: TaggedAsset[]): Map { + const sec = new Map(); + timeline.items.forEach((item) => { + let assetId: string | undefined; + if (item.source.kind === 'user_asset') { + assetId = item.source.assetId; + } else if (item.source.kind === 'fill_artifact') { + const fill = fillForTimelineSource(item.source, fills); + assetId = fill ? assetFromFill(fill, assets)?.id : undefined; + } + if (!assetId) return; + sec.set(assetId, (sec.get(assetId) ?? 0) + Math.max(0, item.endSec - item.startSec)); + }); + return sec; +} + +function seedSourceWindowCursor(timeline: Timeline): SourceWindowCursor { + return seedSourceWindowCursorFromItems(timeline.items); +} + +function seedSourceWindowCursorFromItems(items: TimelineItem[]): SourceWindowCursor { + const cursor: SourceWindowCursor = new Map(); + items.forEach((item) => { + if (item.source.kind !== 'user_asset' || item.sourceOutSec == null) return; + cursor.set(item.source.assetId, Math.max(cursor.get(item.source.assetId) ?? 0, item.sourceOutSec)); + }); + return cursor; +} + +function applyQcAutoRevision(opts: { + timeline: Timeline; + fills: FillArtifact[]; + assets: TaggedAsset[]; + qcReport: MigrationPlan['qcReport']; +}): { + changed: boolean; + timeline: Timeline; + fills: FillArtifact[]; + evidence: Evidence[]; + decisions: Decision[]; +} { + const issueIds = new Set(opts.qcReport.issues.map((issue) => issue.id)); + if (!issueIds.has('qc_first_screen_asset') && !issueIds.has('qc_motion_not_executable')) { + return { changed: false, timeline: opts.timeline, fills: opts.fills, evidence: [], decisions: [] }; + } + + let timeline = opts.timeline; + let fills = opts.fills; + const evidence: Evidence[] = []; + const decisions: Decision[] = []; + + if (issueIds.has('qc_first_screen_asset')) { + const firstScreenRevision = applyFirstScreenQcAutoRevision({ + timeline, + fills, + assets: opts.assets, + }); + if (firstScreenRevision.changed) { + timeline = firstScreenRevision.timeline; + fills = firstScreenRevision.fills; + evidence.push(...firstScreenRevision.evidence); + decisions.push(...firstScreenRevision.decisions); + } + } + + if (issueIds.has('qc_motion_not_executable')) { + const motionRevision = applyExecutableMotionQcAutoRevision({ + timeline, + fills, + assets: opts.assets, + }); + if (motionRevision.changed) { + timeline = motionRevision.timeline; + fills = motionRevision.fills; + evidence.push(...motionRevision.evidence); + decisions.push(...motionRevision.decisions); + } + } + + if (!evidence.length && !decisions.length) { + return { changed: false, timeline: opts.timeline, fills: opts.fills, evidence: [], decisions: [] }; + } + + return { + changed: true, + timeline, + fills, + evidence, + decisions, + }; +} + +function applyFirstScreenQcAutoRevision(opts: { + timeline: Timeline; + fills: FillArtifact[]; + assets: TaggedAsset[]; +}): { + changed: boolean; + timeline: Timeline; + fills: FillArtifact[]; + evidence: Evidence[]; + decisions: Decision[]; +} { + const visualItems = opts.timeline.items + .filter((item) => item.track !== 'audio') + .sort((a, b) => a.startSec - b.startSec); + const firstVisual = visualItems[0]; + if (!firstVisual || !isGeneratedCardSource(firstVisual.source, opts.fills)) { + return { changed: false, timeline: opts.timeline, fills: opts.fills, evidence: [], decisions: [] }; + } + + const replacement = pickQcAutoRevisionAsset(opts.assets, opts.timeline); + if (!replacement) { + return { changed: false, timeline: opts.timeline, fills: opts.fills, evidence: [], decisions: [] }; + } + + const bridgeEndSec = round(Math.min(firstVisual.endSec, firstVisual.startSec + 1.5)); + const shouldKeepRemainder = firstVisual.endSec - bridgeEndSec >= 0.35; + const sourceWindow = sourceWindowFor(replacement, firstVisual.startSec, bridgeEndSec, new Map()); + const revisedItems = opts.timeline.items.flatMap((item) => { + if (item.id !== firstVisual.id) return [item]; + const bridgeItem = { + ...item, + track: 'video' as const, + source: { kind: 'user_asset' as const, assetId: replacement.id }, + endSec: bridgeEndSec, + sourceInSec: sourceWindow?.sourceInSec, + sourceOutSec: sourceWindow?.sourceOutSec, + motionPreset: replacement.mediaType === 'image' + ? (item.motionPreset && item.motionPreset !== 'static' ? item.motionPreset : 'ken_burns_in') + : 'push_in', + cropPreset: replacement.safeCropPreset ?? item.cropPreset, + cardStylePreset: undefined, + cardAnimationPreset: undefined, + }; + if (!shouldKeepRemainder) return [bridgeItem]; + return [ + bridgeItem, + { + ...item, + id: `${item.id}_qc_remainder`, + startSec: bridgeEndSec, + }, + ]; + }); + + return { + changed: true, + timeline: Timeline.parse({ ...opts.timeline, items: normalizeUserAssetSourceWindows(revisedItems, opts.assets) }), + fills: opts.fills, + evidence: [ + { + type: 'qc_auto_revision', + detail: `QC 自动返工:首屏原为生成文字卡,已替换为真实素材 ${replacement.id} 的 ${round(bridgeEndSec - firstVisual.startSec)}s 短桥接${shouldKeepRemainder ? ',原文案卡保留为后续下三分之一 / 包装承接' : ''};保留原 shotRef=${firstVisual.shotRef ?? 'n/a'}。`, + ref: firstVisual.id, + }, + ], + decisions: [ + { + chosen: `replace_first_screen_with_asset:${replacement.id}`, + alternatives: ['keep_generated_card', 'shorten_generated_card', 'rerun_director_plan'], + confidence: 0.72, + reason: 'QC 发现首屏文字卡会削弱抓停效果;在已有真实视觉素材可用时,优先做局部 timeline 修正,避免整条迁移链路重跑带来更大漂移。', + }, + ], + }; +} + +function applyExecutableMotionQcAutoRevision(opts: { + timeline: Timeline; + fills: FillArtifact[]; + assets: TaggedAsset[]; +}): { + changed: boolean; + timeline: Timeline; + fills: FillArtifact[]; + evidence: Evidence[]; + decisions: Decision[]; +} { + const changedItemIds: string[] = []; + let motionIndex = 0; + const revisedItems = opts.timeline.items.map((item) => { + if (item.track === 'audio') return item; + if (item.cropPreset === 'contain') return item; + if (item.motionPreset && item.motionPreset !== 'static') return item; + const asset = assetForTimelineItem(item, opts.fills, opts.assets); + if (!asset || (asset.mediaType !== 'video' && asset.mediaType !== 'image')) return item; + const motionPreset = autoExecutableMotionPreset(asset, item, motionIndex); + motionIndex += 1; + changedItemIds.push(item.id); + return { + ...item, + motionPreset, + }; + }); + + if (!changedItemIds.length) { + return { changed: false, timeline: opts.timeline, fills: opts.fills, evidence: [], decisions: [] }; + } + + return { + changed: true, + timeline: Timeline.parse({ ...opts.timeline, items: revisedItems }), + fills: opts.fills, + evidence: [ + { + type: 'qc_auto_revision', + detail: `QC 自动返工:检测到可执行运镜不足,已为 ${changedItemIds.length} 个真实素材片段写入安全 motionPreset,交由 Remotion / FFmpeg fallback 执行轻微推近、推远或平移。`, + ref: changedItemIds.slice(0, 12).join(', '), + }, + ], + decisions: [ + { + chosen: 'apply_safe_motion_presets', + alternatives: ['ask_user_for_more_motion_assets', 'keep_static_timeline', 'rerun_director_plan'], + confidence: 0.74, + reason: '运镜 preset 属于现有渲染能力可以处理的内部修订;在已有真实素材可用时先自动补齐,不把这类可实现项作为用户下一步动作。', + }, + ], + }; +} + +function assetForTimelineItem( + item: TimelineItem, + fills: FillArtifact[], + assets: TaggedAsset[], +): TaggedAsset | undefined { + const source = item.source; + if (source.kind === 'user_asset') { + return assets.find((asset) => asset.id === source.assetId); + } + const fill = fillForTimelineSource(source, fills); + return fill ? assetFromFill(fill, assets) : undefined; +} + +function autoExecutableMotionPreset(asset: TaggedAsset, item: TimelineItem, index: number): MotionPreset { + if (asset.mediaType === 'image') { + const imagePresets: MotionPreset[] = ['ken_burns_in', 'push_in', 'pan_left', 'pan_right', 'push_out']; + return imagePresets[index % imagePresets.length] ?? 'ken_burns_in'; + } + if (item.startSec <= 2.4 || item.shotRef?.includes('hook')) return 'push_in'; + const videoPresets: MotionPreset[] = ['push_in', 'ken_burns_in', 'push_out']; + return videoPresets[index % videoPresets.length] ?? 'push_in'; +} + +function normalizeUserAssetSourceWindows(items: TimelineItem[], assets: TaggedAsset[]): TimelineItem[] { + const assetById = new Map(assets.map((asset) => [asset.id, asset])); + const cursorByAsset = new Map(); + return items + .slice() + .sort((a, b) => a.startSec - b.startSec) + .map((item) => { + if (item.source.kind !== 'user_asset' || item.sourceInSec == null || item.sourceOutSec == null) return item; + const assetId = item.source.assetId; + const cursor = cursorByAsset.get(assetId); + const duration = Math.max(0.1, item.sourceOutSec - item.sourceInSec); + let sourceInSec = item.sourceInSec; + let sourceOutSec = item.sourceOutSec; + if (cursor != null && sourceInSec < cursor - 0.05) { + const assetDuration = assetById.get(assetId)?.durationSec; + sourceInSec = cursor; + sourceOutSec = round(sourceInSec + duration); + if (assetDuration && sourceOutSec > assetDuration) { + sourceInSec = Math.max(0, round(assetDuration - duration)); + sourceOutSec = round(sourceInSec + duration); + } + } + cursorByAsset.set(assetId, Math.max(cursor ?? 0, sourceOutSec)); + return { + ...item, + sourceInSec: round(sourceInSec), + sourceOutSec: round(sourceOutSec), + }; + }); +} + +function pickQcAutoRevisionAsset(assets: TaggedAsset[], timeline: Timeline): TaggedAsset | undefined { + const usedAssetIds = new Set( + timeline.items + .map((item) => item.source.kind === 'user_asset' ? item.source.assetId : undefined) + .filter((assetId): assetId is string => Boolean(assetId)), + ); + return reusableAssets(assets) + .sort((a, b) => qcRevisionAssetScore(b, usedAssetIds) - qcRevisionAssetScore(a, usedAssetIds)) + [0]; +} + +function qcRevisionAssetScore(asset: TaggedAsset, usedAssetIds: Set): number { + const functions = asset.visualFunctions ?? []; + const functionScore = + (functions.includes('introduce_subject') ? 1.2 : 0) + + (functions.includes('show_detail') ? 1 : 0) + + (functions.includes('show_result') ? 0.9 : 0) + + (functions.includes('establish_context') ? 0.8 : 0); + const scaleScore = asset.shotScale === 'close' || asset.shotScale === 'medium' ? 0.35 : 0; + const freshnessScore = usedAssetIds.has(asset.id) ? -0.35 : 0.25; + return asset.confidence + functionScore + scaleScore + freshnessScore; +} + +function cardTreatmentForPattern( + pattern: LearnedSamplePattern | undefined, + blueprint: VideoStructureBlueprint, +): { style: CardStylePreset; animation: CardAnimationPreset } { + const packaging = pattern?.packaging ?? blueprint.packagingStructure; + const styleText = [ + packaging?.titleBarStyle, + packaging?.stickerUsage, + packaging?.coverStyle, + pattern?.formula, + ...(pattern?.tags ?? []), + ] + .filter(Boolean) + .join(' '); + const transitionText = [packaging?.transitionStyle, pattern?.pacing.cutDensity].filter(Boolean).join(' '); + + return { + style: cardStyleFromPatternText(styleText), + animation: cardAnimationFromPatternText(transitionText, pattern?.pacing.cutDensity ?? blueprint.rhythmStructure.cutDensity), }; } +function cardStyleFromPatternText(text: string): CardStylePreset { + if (/大字|卖点|关键词|高亮|快切|弹出|social|punch|种草/i.test(text)) return 'social_punch'; + if (/高级|干净|留白|商品|质感|clean|minimal|product/i.test(text)) return 'clean_product'; + if (/生活|氛围|vlog|旅行|咖啡|日常|治愈|暖调|柔光|香气|lifestyle|story|coffee|cafe|morning/i.test(text)) return 'lifestyle_story'; + if (/贴纸|箭头|圈选|sticker|pop/i.test(text)) return 'sticker_pop'; + if (/标题条|色块|title|bar/i.test(text)) return 'title_bar'; + if (/封面|大字|报价|主图|cover|poster/i.test(text)) return 'cover_card'; + if (/字幕|caption|下三分之一|lower/i.test(text)) return 'editorial_caption'; + return 'minimal_dark'; +} + +function cardAnimationFromPatternText(text: string, density: string): CardAnimationPreset { + if (/叠化|渐隐|渐变|fade|cross/i.test(text)) return 'soft_crossfade'; + if (/滑|推|横扫|slide|swipe|smooth/i.test(text)) return 'slide_left'; + if (/上|下|wipe/i.test(text)) return 'wipe_up'; + if (/闪|硬切|快切|跳切|snap|pop/i.test(text) || density === 'high') return 'snap_pop'; + return 'fade_push'; +} + +function overlapCount(a: Set, b: Set): number { + let count = 0; + for (const value of a) { + if (b.has(value)) count += 1; + } + return count; +} + function segmentSpan(blueprint: VideoStructureBlueprint, slotId: string, durationSec: number) { const slot = blueprint.slots.find((s) => s.id === slotId); const idx = blueprint.scriptStructure.segments.findIndex((s) => s.role === slot?.segmentRole); @@ -283,13 +4697,13 @@ function scriptText( ): string { const point = sellingPoints[0] ?? '核心信息'; const second = sellingPoints[1] ?? point; - const pattern = copyPattern ? `(${copyPattern})` : ''; + void copyPattern; const map: Record, string> = { - hook: `${topic}:用反差或悬念在开场抓住注意力。${pattern}`, - setup: `交代背景或抛出核心问题,让观众代入 ${topic}。${pattern}`, - develop: `围绕 ${topic} 展开主体内容:${point}。${pattern}`, - climax: `把最有冲击力的点放在这里:${second}。${pattern}`, - closing: `收束 ${topic},给出明确的结尾表达或行动。${pattern}`, + hook: `${topic},先看最直接的变化。`, + setup: `从日常场景开始,看看 ${topic} 为什么值得关注。`, + develop: `${point},这是这条内容的关键看点。`, + climax: `${second},把差异放到最清楚的一刻。`, + closing: `记住这个选择:${topic}。`, }; return map[role]; } @@ -304,9 +4718,10 @@ function visualText( return `${roleName(role)}:缺 ${slot.requiredAssetTypes.join('/')},用文字卡补全`; } -function fillText(topic: string, sellingPoints: string[], slotId: string): string { +function fillText(topic: string, sellingPoints: string[]): string { const point = sellingPoints[0] ?? topic; - return `${topic}\n${point}\n补全 ${slotId}`; + const cue = point === topic ? '先看这个关键瞬间' : '看这个关键看点'; + return `${topic}\n${point}\n${cue}`; } function roleName(role: z.infer): string { diff --git a/apps/api/src/core/migration/assetMatching.ts b/apps/api/src/core/migration/assetMatching.ts new file mode 100644 index 0000000..aca7606 --- /dev/null +++ b/apps/api/src/core/migration/assetMatching.ts @@ -0,0 +1,96 @@ +import type { StructureSlot, TaggedAsset } from '../slot'; + +const MIN_CONFIDENCE = 0.3; + +export type SlotMatchResult = { + slotId: string; + assetId?: string; + score: number; + status: 'matched' | 'gap'; + reason: string; +}; + +export function reusableAssets(assets: T[]): T[] { + return assets.filter((asset) => asset.mediaType === 'video' || asset.mediaType === 'image'); +} + +export function matchSlot( + slot: StructureSlot, + assets: TaggedAsset[], + usageByAsset = new Map(), +): SlotMatchResult { + const requiredForScore = matchableRequiredTags(slot); + const ranked = assets + .map((asset) => { + const effectiveTags = effectiveAssetTags(asset); + const missingStrictTags = strictRequiredTags(slot).filter((tag) => !asset.assetTags.includes(tag)); + const overlap = effectiveTags.filter((tag) => requiredForScore.includes(tag)).length; + const tagScore = overlap / requiredForScore.length; + const requiredFunctions = slot.requiredVisualFunctions ?? []; + const functionOverlap = requiredFunctions.filter((fn) => (asset.visualFunctions ?? []).includes(fn)).length; + const functionScore = requiredFunctions.length ? functionOverlap / requiredFunctions.length : 1; + const durationOk = !slot.minDurationSec || (asset.durationSec ?? 0) >= slot.minDurationSec; + const confidenceOk = asset.confidence >= MIN_CONFIDENCE; + // 时长不足只降权、不淘汰:迁移到更短目标时,单条素材无需填满整段(可裁切 / 复用)。 + const durationPenalty = durationOk ? 1 : 0.6; + const used = usageByAsset.get(asset.id) ?? 0; + const diversityPenalty = Math.max(0.55, 1 - used * 0.2); + const score = + confidenceOk && overlap > 0 && missingStrictTags.length === 0 && (requiredFunctions.length === 0 || functionOverlap > 0) + ? round(Math.min(1, tagScore * functionScore * asset.confidence * durationPenalty * diversityPenalty)) + : 0; + return { asset, overlap, functionOverlap, durationOk, confidenceOk, missingStrictTags, score, used }; + }) + .sort((a, b) => b.score - a.score || a.used - b.used || b.overlap - a.overlap); + + const best = ranked[0]; + if (best && best.score > 0) { + const shortNote = best.durationOk ? '' : '(时长偏短,渲染时裁切 / 复用)'; + const reuseNote = best.used > 0 ? `,已复用 ${best.used} 次后做多样性降权` : ''; + const matchedTags = effectiveAssetTags(best.asset).filter((tag) => slot.requiredAssetTypes.includes(tag)); + const matchedFunctions = (best.asset.visualFunctions ?? []).filter((fn) => + (slot.requiredVisualFunctions ?? []).includes(fn), + ); + const brollFallbackNote = + slot.requiredAssetTypes.includes('b_roll') && !best.asset.assetTags.includes('b_roll') + ? '(普通视觉素材按 b_roll 兜底)' + : ''; + return { + slotId: slot.id, + assetId: best.asset.id, + score: best.score, + status: 'matched', + reason: `命中标签 ${matchedTags.join('/')}${matchedFunctions.length ? `,视觉功能 ${matchedFunctions.join('/')}` : ''},置信度 ${best.asset.confidence}${shortNote}${reuseNote}${brollFallbackNote}`, + }; + } + + const strictMiss = ranked.find((r) => r.overlap > 0 && r.missingStrictTags.length > 0); + const hasTagOverlap = ranked.some((r) => r.overlap > 0); + const reason = strictMiss + ? `有通用画面可复用,但缺少必须真实呈现的 ${strictMiss.missingStrictTags.join('/')}` + : hasTagOverlap + ? `有标签相近素材,但时长或置信度不足(minDurationSec=${slot.minDurationSec ?? 'n/a'})` + : `没有素材命中 ${slot.requiredAssetTypes.join('/')}${slot.requiredVisualFunctions?.length ? ` + ${slot.requiredVisualFunctions.join('/')}` : ''}`; + return { slotId: slot.id, score: 0, status: 'gap', reason }; +} + +function effectiveAssetTags(asset: TaggedAsset): TaggedAsset['assetTags'] { + if (asset.mediaType !== 'video' && asset.mediaType !== 'image') return asset.assetTags; + return asset.assetTags.includes('b_roll') ? asset.assetTags : [...asset.assetTags, 'b_roll']; +} + +function matchableRequiredTags(slot: StructureSlot): TaggedAsset['assetTags'] { + return slot.requiredAssetTypes.filter((tag) => tag !== 'text_card' || isStandaloneTextSlot(slot)); +} + +function strictRequiredTags(slot: StructureSlot): TaggedAsset['assetTags'] { + return slot.requiredAssetTypes.filter((tag) => tag === 'talking_head' || (tag === 'text_card' && isStandaloneTextSlot(slot))); +} + +function isStandaloneTextSlot(slot: StructureSlot): boolean { + return slot.requiredAssetTypes.includes('text_card') && !slot.requiredAssetTypes.some((tag) => tag !== 'text_card'); +} + +function round(n: number): number { + return Number(n.toFixed(3)); +} diff --git a/apps/api/src/core/migrationControls.ts b/apps/api/src/core/migrationControls.ts new file mode 100644 index 0000000..374f421 --- /dev/null +++ b/apps/api/src/core/migrationControls.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +export const MigrationLocks = z.object({ + /** 强制优先使用这些样例库 pattern;按数组顺序取第一个可用 pattern。 */ + lockedPatternIds: z.array(z.string()).default([]), + /** 暂存 UI 选择的 shot 锁;当前用于解释和后续编辑器保留,重生成时仍需配合已有 migration。 */ + lockedShotIds: z.array(z.string()).default([]), + /** 将 Director shotId 或 slotId 指定到某个用户素材 id。 */ + shotAssetAssignments: z.record(z.string()).default({}), +}); +export type MigrationLocks = z.infer; + +export const TimedTranscriptCue = z.object({ + startSec: z.number().min(0), + endSec: z.number().min(0), + text: z.string(), + confidence: z.number().min(0).max(1).optional(), +}).refine((cue) => cue.endSec > cue.startSec, { message: 'endSec 必须大于 startSec' }); +export type TimedTranscriptCue = z.infer; + +export const MusicSection = z.object({ + startSec: z.number().min(0), + endSec: z.number().min(0), + kind: z.enum(['intro', 'verse', 'build', 'drop', 'chorus', 'break', 'outro', 'unknown']).default('unknown'), + confidence: z.number().min(0).max(1).default(0.5), + downbeatsSec: z.array(z.number().min(0)).default([]), +}).refine((section) => section.endSec > section.startSec, { message: 'endSec 必须大于 startSec' }); +export type MusicSection = z.infer; + +export const ClipScore = z.object({ + assetId: z.string(), + score: z.number().min(0).max(1), + reasons: z.array(z.string()).default([]), + highlightWindows: z.array( + z.object({ + startSec: z.number().min(0), + endSec: z.number().min(0), + score: z.number().min(0).max(1), + reason: z.string().optional(), + }).refine((window) => window.endSec > window.startSec, { message: 'endSec 必须大于 startSec' }), + ).default([]), +}); +export type ClipScore = z.infer; + +export const SafeCropHint = z.object({ + assetId: z.string(), + preset: z.enum(['center', 'top', 'bottom', 'left', 'right', 'closeup']), + reason: z.enum(['face', 'product', 'action', 'landscape', 'manual', 'unknown']).default('unknown'), + confidence: z.number().min(0).max(1).default(0.5), +}); +export type SafeCropHint = z.infer; + +export const MigrationSignalInputs = z.object({ + transcriptCues: z.array(TimedTranscriptCue).default([]), + musicSections: z.array(MusicSection).default([]), + clipScores: z.array(ClipScore).default([]), + safeCropHints: z.array(SafeCropHint).default([]), +}); +export type MigrationSignalInputs = z.infer; + +export const MigrationControls = z.object({ + locks: MigrationLocks.default({}), + signals: MigrationSignalInputs.default({}), +}); +export type MigrationControls = z.infer; +export type MigrationControlsInput = z.input; diff --git a/apps/api/src/core/mocks/sample-analysis.ts b/apps/api/src/core/mocks/sample-analysis.ts index 3bd9c3e..b68c398 100644 --- a/apps/api/src/core/mocks/sample-analysis.ts +++ b/apps/api/src/core/mocks/sample-analysis.ts @@ -19,6 +19,7 @@ export const sampleAnalysis: SampleAnalysis = { { index: 3, atSec: 20.0 }, ], shotCount: 4, + transcriptCues: [], keyframes: [ { atSec: 0, imagePath: 'out/analysis/sample_001/keyframes/kf_0.jpg', sceneIndex: 0 }, { atSec: 4, imagePath: 'out/analysis/sample_001/keyframes/kf_1.jpg', sceneIndex: 1 }, diff --git a/apps/api/src/core/rhythm.ts b/apps/api/src/core/rhythm.ts new file mode 100644 index 0000000..b1a1877 --- /dev/null +++ b/apps/api/src/core/rhythm.ts @@ -0,0 +1,980 @@ +import { z } from 'zod'; +import type { VideoStructureBlueprint } from './blueprint'; +import type { SegmentRole, StoryFunction } from './enums'; +import type { LearnedSamplePattern } from './sampleLearning'; +import type { SampleAnalysis } from './sample'; +import type { BeatGrid } from './timeline'; + +export const RhythmSource = z.enum(['user_sample', 'global_match', 'target_music', 'estimated']); +export type RhythmSource = z.infer; + +export const RhythmEventType = z.enum(['cut', 'caption', 'title', 'transition', 'emphasis']); +export type RhythmEventType = z.infer; + +export const MusicEnergyShape = z.enum(['steady', 'front_loaded', 'mid_peak', 'late_peak', 'rising', 'unknown']); +export type MusicEnergyShape = z.infer; + +export const MusicSectionKind = z.enum(['intro', 'verse', 'build', 'drop', 'chorus', 'break', 'outro', 'unknown']); +export type MusicSectionKind = z.infer; + +export const MusicSection = z.object({ + startSec: z.number().min(0), + endSec: z.number().min(0), + kind: MusicSectionKind.default('unknown'), + confidence: z.number().min(0).max(1).default(0.5), + downbeatsSec: z.array(z.number().min(0)).default([]), +}).refine((section) => section.endSec > section.startSec, { message: 'endSec 必须大于 startSec' }); +export type MusicSection = z.infer; + +export const MusicFingerprint = z.object({ + hasAudio: z.boolean(), + durationSec: z.number().positive(), + bpm: z.number().positive().optional(), + beatCount: z.number().int().nonnegative().default(0), + beatStability: z.number().min(0).max(1).default(0), + onsetDensity: z.number().min(0).default(0), + energyShape: MusicEnergyShape.default('unknown'), + peakAt: z.number().min(0).max(1).default(0.5), + confidence: z.enum(['none', 'low', 'medium', 'high']).default('low'), + downbeatsSec: z.array(z.number().min(0)).default([]), + phraseBoundariesSec: z.array(z.number().min(0)).default([]), + sections: z.array(MusicSection).default([]), + tags: z.array(z.string()).default([]), +}); +export type MusicFingerprint = z.infer; + +export const RhythmEditEvent = z.object({ + eventType: RhythmEventType, + timeSec: z.number().min(0), + relativeTime: z.number().min(0).max(1), + beatIndex: z.number().int().nonnegative().optional(), + phraseIndex: z.number().int().nonnegative().optional(), + nearestBeatSec: z.number().min(0).optional(), + offsetMs: z.number().default(0), + segmentRole: z.custom().optional(), + storyFunction: z.custom().optional(), + strength: z.enum(['weak', 'medium', 'strong']).default('medium'), + description: z.string().default(''), +}); +export type RhythmEditEvent = z.infer; + +export const RhythmShotPattern = z.object({ + cutDensity: z.string(), + shotCount: z.number().int().nonnegative(), + avgShotSec: z.number().positive(), + peakAt: z.number().min(0).max(1), + cutEveryBeats: z.number().positive().optional(), + phraseLengthBeats: z.number().int().positive().default(8), +}); +export type RhythmShotPattern = z.infer; + +export const RhythmProfile = z.object({ + id: z.string(), + source: z.enum(['user_sample', 'global_sample']), + durationSec: z.number().positive(), + music: MusicFingerprint, + shotPattern: RhythmShotPattern, + events: z.array(RhythmEditEvent).default([]), + cutIntervalsSec: z.array(z.number().positive()).default([]), + captionStrategy: z.string().default(''), + strategySummary: z.string().default(''), +}); +export type RhythmProfile = z.infer; + +export const MatchedGlobalRhythmPattern = z.object({ + patternId: z.string(), + name: z.string(), + score: z.number().min(0).max(1), + bpm: z.number().positive().optional(), + reason: z.string(), +}); +export type MatchedGlobalRhythmPattern = z.infer; + +export const RhythmAlignmentEvent = RhythmEditEvent.extend({ + sourceTimeSec: z.number().min(0), + targetTimeSec: z.number().min(0), + sourcePatternId: z.string().optional(), + confidence: z.number().min(0).max(1).default(0.7), +}); +export type RhythmAlignmentEvent = z.infer; + +export const RhythmAnchor = z.object({ + source: RhythmSource, + beatIndex: z.number().int().nonnegative().optional(), + phraseIndex: z.number().int().nonnegative().optional(), + preferredTimeSec: z.number().min(0).optional(), + actualTimeSec: z.number().min(0).optional(), + nearestBeatSec: z.number().min(0).optional(), + offsetMs: z.number().default(0), + toleranceMs: z.number().positive().default(160), + lockStrength: z.enum(['hard', 'soft']).default('soft'), + rationale: z.string().default(''), +}); +export type RhythmAnchor = z.infer; + +export const PacingPhaseRole = z.enum(['setup', 'accelerate', 'hold', 'climax', 'payoff']); +export type PacingPhaseRole = z.infer; + +export const PacingCutDensity = z.enum(['low', 'medium', 'high', 'burst']); +export type PacingCutDensity = z.infer; + +export const PacingEnvelopePhase = z.object({ + role: PacingPhaseRole, + startRatio: z.number().min(0).max(1), + endRatio: z.number().min(0).max(1), + avgShotSec: z.number().positive(), + cutDensity: PacingCutDensity.default('medium'), + source: z.enum(['user_sample', 'global_match', 'blended']).default('blended'), + rationale: z.string().default(''), +}).refine((phase) => phase.endRatio > phase.startRatio, { message: 'endRatio 必须大于 startRatio' }); +export type PacingEnvelopePhase = z.infer; + +export const PacingEnvelope = z.object({ + id: z.string(), + source: z.enum(['user_sample', 'global_match', 'blended']), + sampleWeight: z.number().min(0).max(1), + globalWeight: z.number().min(0).max(1), + phases: z.array(PacingEnvelopePhase).min(1), + qcAdjustmentPolicy: z.string().default(''), + rationale: z.string().default(''), +}); +export type PacingEnvelope = z.infer; + +export const RhythmAlignmentPlan = z.object({ + strategy: z.enum([ + 'same_bgm_direct', + 'beat_index_mapping', + 'global_music_match', + 'target_music_regenerate', + 'sample_structure_estimated', + ]), + sourcePriority: z.array(RhythmSource).min(1), + targetMusic: MusicFingerprint, + sampleProfile: RhythmProfile.optional(), + matchedGlobalPattern: MatchedGlobalRhythmPattern.optional(), + pacingEnvelope: PacingEnvelope.optional(), + events: z.array(RhythmAlignmentEvent).default([]), + qualityTargets: z.object({ + cutToBeatToleranceMs: z.number().positive().default(160), + highEnergyMaxShotSec: z.number().positive().default(1.4), + staticMaxSec: z.number().positive().default(1.5), + }), + rationale: z.string().default(''), +}); +export type RhythmAlignmentPlan = z.infer; + +export interface RhythmPatternMatch { + pattern: LearnedSamplePattern; + score: number; + reasons: string[]; +} + +export function fallbackBpmForDensity(cutDensity: string): number { + return cutDensity === 'high' ? 128 : cutDensity === 'medium' ? 112 : 92; +} + +export function createMusicFingerprintFromGrid( + beatGrid: BeatGrid, + durationSec: number, + cutDensity: string, + detected?: { sections?: MusicSection[] }, +): MusicFingerprint { + const intervals = beatGrid.beatsSec + .slice(1) + .map((beat, i) => beat - beatGrid.beatsSec[i]) + .filter((v) => v > 0); + const median = medianNumber(intervals); + const variance = median + ? intervals.reduce((sum, interval) => sum + Math.abs(interval - median), 0) / Math.max(1, intervals.length) + : 0; + const beatStability = median ? clamp01(1 - variance / median) : 0; + const shape = cutDensity === 'high' ? 'rising' : 'steady'; + const downbeatsSec = detected?.sections?.flatMap((section) => section.downbeatsSec) ?? inferDownbeatsFromBeats(beatGrid.beatsSec); + const phraseBoundariesSec = inferPhraseBoundariesFromBeats(beatGrid.beatsSec); + const detectedSections = detected?.sections?.length ? detected.sections : undefined; + return MusicFingerprint.parse({ + hasAudio: beatGrid.source === 'detected', + durationSec, + bpm: beatGrid.bpm, + beatCount: beatGrid.beatsSec.length, + beatStability, + onsetDensity: beatGrid.beatsSec.length / Math.max(1, durationSec), + energyShape: shape, + peakAt: 0.55, + confidence: beatGrid.confidence, + downbeatsSec, + phraseBoundariesSec, + sections: detectedSections ?? inferMusicSections(durationSec, downbeatsSec, shape), + tags: [ + `bpm:${Math.round(beatGrid.bpm)}`, + `density:${cutDensity}`, + `source:${beatGrid.source}`, + `shape:${shape}`, + ], + }); +} + +export function createMusicFingerprintFromSample( + analysis: SampleAnalysis, + blueprint: VideoStructureBlueprint, +): MusicFingerprint { + const durationSec = Math.max(0.1, analysis.metadata.durationSec); + const bpm = fallbackBpmForDensity(blueprint.rhythmStructure.cutDensity); + const step = 60 / bpm; + const cutTimes = cutTimesFromSample(analysis, blueprint).filter((time) => time > 0 && time < durationSec); + const intervals = intervalsFromTimes([0, ...cutTimes, durationSec]); + const cutStability = stabilityFromIntervals(intervals, blueprint.rhythmStructure.avgShotSec); + const beatHints = blueprint.rhythmStructure.bgmBeatHints.length; + const energyShape = energyShapeForPeak(blueprint.rhythmStructure.peakAt); + const estimatedBeats = Array.from({ length: Math.max(0, Math.round(durationSec / step)) }, (_, index) => round(index * step)); + const downbeatsSec = inferDownbeatsFromBeats(estimatedBeats); + const phraseBoundariesSec = inferPhraseBoundariesFromBeats(estimatedBeats); + return MusicFingerprint.parse({ + hasAudio: analysis.metadata.hasAudio, + durationSec, + bpm, + beatCount: Math.max(0, Math.round(durationSec / step)), + beatStability: cutStability, + onsetDensity: analysis.shotCount / durationSec, + energyShape, + peakAt: blueprint.rhythmStructure.peakAt, + confidence: analysis.metadata.hasAudio ? (beatHints ? 'medium' : 'low') : 'none', + downbeatsSec, + phraseBoundariesSec, + sections: inferMusicSections(durationSec, downbeatsSec, energyShape), + tags: [ + blueprint.videoGenre, + `density:${blueprint.rhythmStructure.cutDensity}`, + `bpm:${bpm}`, + `shape:${energyShape}`, + ], + }); +} + +export function createRhythmProfileFromSample(input: { + sampleId: string; + analysis: SampleAnalysis; + blueprint: VideoStructureBlueprint; + source?: 'user_sample' | 'global_sample'; +}): RhythmProfile { + const { analysis, blueprint } = input; + const music = createMusicFingerprintFromSample(analysis, blueprint); + const durationSec = Math.max(0.1, analysis.metadata.durationSec); + const estimatedBeats = estimatedBeatsFor(durationSec, music.bpm ?? fallbackBpmForDensity(blueprint.rhythmStructure.cutDensity)); + const cutTimes = cutTimesFromSample(analysis, blueprint).filter((time) => time > 0 && time < durationSec); + const segmentEvents = segmentBoundaryEvents(blueprint, durationSec, estimatedBeats); + const cutEvents = cutTimes.map((time, index) => + eventForTime({ + eventType: 'cut', + timeSec: time, + durationSec, + beatsSec: estimatedBeats, + strength: index === 0 || index === cutTimes.length - 1 ? 'strong' : 'medium', + description: `样例第 ${index + 1} 个切镜点`, + }), + ); + const cutIntervalsSec = intervalsFromTimes([0, ...cutTimes, durationSec]); + const cutEveryBeats = medianNumber(cutIntervalsSec.map((interval) => interval / (60 / (music.bpm ?? 112)))); + return RhythmProfile.parse({ + id: `rhythm_${input.sampleId}`, + source: input.source ?? 'user_sample', + durationSec, + music, + shotPattern: { + cutDensity: blueprint.rhythmStructure.cutDensity, + shotCount: analysis.shotCount, + avgShotSec: blueprint.rhythmStructure.avgShotSec, + peakAt: blueprint.rhythmStructure.peakAt, + cutEveryBeats: cutEveryBeats ? round(cutEveryBeats) : undefined, + phraseLengthBeats: 8, + }, + events: [...cutEvents, ...segmentEvents].sort((a, b) => a.timeSec - b.timeSec), + cutIntervalsSec, + captionStrategy: captionStrategyFor(blueprint), + strategySummary: `样例节奏:${analysis.shotCount} 镜,平均 ${blueprint.rhythmStructure.avgShotSec.toFixed(1)}s/镜,${densityName( + blueprint.rhythmStructure.cutDensity, + )};切镜事件按 beat index 存储,迁移时映射到目标 BGM。`, + }); +} + +export function createRhythmProfileFromBlueprint(input: { + sampleId: string; + blueprint: VideoStructureBlueprint; + durationSec: number; +}): RhythmProfile { + const durationSec = Math.max(0.1, input.durationSec); + const bpm = fallbackBpmForDensity(input.blueprint.rhythmStructure.cutDensity); + const beatsSec = estimatedBeatsFor(durationSec, bpm); + const cutTimes = (input.blueprint.rhythmStructure.shots ?? []) + .map((shot) => shot.endSec) + .filter((time) => time > 0 && time < durationSec); + const fallbackCuts = cutTimes.length ? cutTimes : cumulativeSegmentTimes(input.blueprint, durationSec).slice(0, -1); + const events = [ + ...fallbackCuts.map((time, index) => + eventForTime({ + eventType: 'cut', + timeSec: time, + durationSec, + beatsSec, + strength: index === 0 ? 'strong' : 'medium', + description: `用户样例结构第 ${index + 1} 个节奏切点`, + }), + ), + ...segmentBoundaryEvents(input.blueprint, durationSec, beatsSec), + ].sort((a, b) => a.timeSec - b.timeSec); + const music = MusicFingerprint.parse({ + hasAudio: false, + durationSec, + bpm, + beatCount: beatsSec.length, + beatStability: 0.55, + onsetDensity: fallbackCuts.length / durationSec, + energyShape: energyShapeForPeak(input.blueprint.rhythmStructure.peakAt), + peakAt: input.blueprint.rhythmStructure.peakAt, + confidence: input.blueprint.rhythmStructure.bgmBeatHints.length ? 'medium' : 'low', + tags: [input.blueprint.videoGenre, `density:${input.blueprint.rhythmStructure.cutDensity}`, `bpm:${bpm}`], + }); + return RhythmProfile.parse({ + id: `rhythm_${input.sampleId}`, + source: 'user_sample', + durationSec, + music, + shotPattern: { + cutDensity: input.blueprint.rhythmStructure.cutDensity, + shotCount: Math.max(1, fallbackCuts.length + 1), + avgShotSec: input.blueprint.rhythmStructure.avgShotSec, + peakAt: input.blueprint.rhythmStructure.peakAt, + cutEveryBeats: round(input.blueprint.rhythmStructure.avgShotSec / (60 / bpm)), + phraseLengthBeats: 8, + }, + events, + cutIntervalsSec: intervalsFromTimes([0, ...fallbackCuts, durationSec]), + captionStrategy: captionStrategyFor(input.blueprint), + strategySummary: '用户样例 rhythm profile 来自结构蓝图;若目标 BGM 不同,会按 beat index / phrase 映射。', + }); +} + +export function inferMusicFingerprintFromPattern(pattern: LearnedSamplePattern): MusicFingerprint { + if (pattern.musicFingerprint) return pattern.musicFingerprint; + if (pattern.rhythmProfile?.music) return pattern.rhythmProfile.music; + const bpm = fallbackBpmForDensity(pattern.pacing.cutDensity); + const durationSec = Math.max(0.1, pattern.source.durationSec); + return MusicFingerprint.parse({ + hasAudio: pattern.learnedDimensions.bgmSync.hasAudio, + durationSec, + bpm, + beatCount: Math.round(durationSec / (60 / bpm)), + beatStability: pattern.learnedDimensions.bgmSync.confidence === 'high' ? 0.85 : 0.55, + onsetDensity: Math.max(0, pattern.pacing.shotCount / durationSec), + energyShape: energyShapeForPeak(pattern.pacing.peakAt), + peakAt: pattern.pacing.peakAt, + confidence: pattern.learnedDimensions.bgmSync.confidence, + tags: [pattern.videoGenre, `density:${pattern.pacing.cutDensity}`, `bpm:${bpm}`], + }); +} + +export function rhythmProfileFromPattern(pattern: LearnedSamplePattern): RhythmProfile { + if (pattern.rhythmProfile) return pattern.rhythmProfile; + const durationSec = Math.max(0.1, pattern.source.durationSec); + const music = inferMusicFingerprintFromPattern(pattern); + const bpm = music.bpm ?? fallbackBpmForDensity(pattern.pacing.cutDensity); + const beatsSec = estimatedBeatsFor(durationSec, bpm); + const segmentCuts = pattern.segments.reduce((acc, segment, index) => { + if (index >= pattern.segments.length - 1) return acc; + const prev = acc[index - 1] ?? 0; + acc.push(round(prev + segment.durationRatio * durationSec)); + return acc; + }, []); + const events = [ + ...segmentCuts.map((time, index) => + eventForTime({ + eventType: 'cut', + timeSec: time, + durationSec, + beatsSec, + strength: index === 0 ? 'strong' : 'medium', + description: `全局样例 pattern 段落切点 ${index + 1}`, + }), + ), + ...pattern.segments.map((segment, index) => + eventForTime({ + eventType: index === 0 ? 'title' : 'caption', + timeSec: round(cumulativeRatio(pattern.segments, index) * durationSec), + durationSec, + beatsSec, + strength: index === 0 ? 'strong' : 'medium', + segmentRole: segment.role, + description: segment.watchingPurpose || segment.intent, + }), + ), + ].sort((a, b) => a.timeSec - b.timeSec); + return RhythmProfile.parse({ + id: `rhythm_${pattern.id}`, + source: 'global_sample', + durationSec, + music, + shotPattern: { + cutDensity: pattern.pacing.cutDensity, + shotCount: pattern.pacing.shotCount, + avgShotSec: pattern.pacing.avgShotSec, + peakAt: pattern.pacing.peakAt, + cutEveryBeats: round(pattern.pacing.avgShotSec / (60 / bpm)), + phraseLengthBeats: 8, + }, + events, + cutIntervalsSec: intervalsFromTimes([0, ...segmentCuts, durationSec]), + captionStrategy: pattern.learnedDimensions.subtitleStyle.placement, + strategySummary: pattern.bgmSyncPattern?.syncStrategy ?? pattern.learnedDimensions.bgmSync.syncStrategy, + }); +} + +export function rankRhythmPatternsForTarget( + patterns: LearnedSamplePattern[], + targetMusic: MusicFingerprint, + blueprint: VideoStructureBlueprint, +): RhythmPatternMatch[] { + return [...patterns] + .map((pattern) => { + const music = inferMusicFingerprintFromPattern(pattern); + const bpmScore = similarityByRelativeDistance(music.bpm, targetMusic.bpm, 0.45); + const onsetScore = similarityByRelativeDistance(music.onsetDensity, targetMusic.onsetDensity, 0.7); + const peakScore = 1 - Math.min(1, Math.abs(music.peakAt - targetMusic.peakAt) / 0.55); + const densityScore = pattern.pacing.cutDensity === blueprint.rhythmStructure.cutDensity ? 1 : 0.55; + const genreScore = pattern.videoGenre === blueprint.videoGenre ? 1 : 0.72; + const confidenceBonus = confidenceWeight(music.confidence) * 0.08; + const profileBonus = pattern.rhythmProfile ? 0.08 : 0; + const score = clamp01( + bpmScore * 0.34 + + onsetScore * 0.2 + + peakScore * 0.16 + + densityScore * 0.13 + + genreScore * 0.09 + + confidenceBonus + + profileBonus, + ); + const reasons = [ + `BPM ${Math.round(music.bpm ?? 0)} vs ${Math.round(targetMusic.bpm ?? 0)}`, + `onsetDensity ${round(music.onsetDensity)} vs ${round(targetMusic.onsetDensity)}`, + `peakAt ${round(music.peakAt)} vs ${round(targetMusic.peakAt)}`, + `cutDensity=${pattern.pacing.cutDensity}`, + ]; + return { pattern, score: round(score), reasons }; + }) + .sort((a, b) => b.score - a.score || b.pattern.updatedAt.localeCompare(a.pattern.updatedAt)); +} + +export function buildRhythmAlignmentPlan(input: { + sampleProfile: RhythmProfile; + targetGrid: BeatGrid; + targetMusic: MusicFingerprint; + learnedPatterns: LearnedSamplePattern[]; + blueprint: VideoStructureBlueprint; +}): RhythmAlignmentPlan { + const ranked = rankRhythmPatternsForTarget(input.learnedPatterns, input.targetMusic, input.blueprint); + const best = ranked[0]; + const useGlobal = input.targetGrid.source === 'detected' && best && best.score >= 0.66; + const sourceProfile = useGlobal ? rhythmProfileFromPattern(best.pattern) : input.sampleProfile; + const sourcePatternId = useGlobal ? best.pattern.id : input.sampleProfile.id; + const events = mapProfileEventsToTarget(sourceProfile, input.targetGrid, input.targetMusic.durationSec, sourcePatternId); + const strategy = input.targetGrid.source === 'detected' + ? useGlobal + ? 'global_music_match' + : 'beat_index_mapping' + : 'sample_structure_estimated'; + const matchedGlobalPattern = useGlobal + ? { + patternId: best.pattern.id, + name: best.pattern.reusablePatternName, + score: best.score, + bpm: inferMusicFingerprintFromPattern(best.pattern).bpm, + reason: best.reasons.join(';'), + } + : undefined; + const density = useGlobal ? best.pattern.pacing.cutDensity : input.blueprint.rhythmStructure.cutDensity; + const cutToBeatToleranceMs = density === 'high' ? 120 : 160; + return RhythmAlignmentPlan.parse({ + strategy, + sourcePriority: useGlobal + ? ['global_match', 'target_music', 'user_sample'] + : input.targetGrid.source === 'detected' + ? ['user_sample', 'target_music'] + : ['user_sample', 'estimated'], + targetMusic: input.targetMusic, + sampleProfile: input.sampleProfile, + matchedGlobalPattern, + events, + qualityTargets: { + cutToBeatToleranceMs, + highEnergyMaxShotSec: density === 'high' ? 1.2 : 1.5, + staticMaxSec: density === 'high' ? 1.2 : 1.5, + }, + rationale: useGlobal + ? `目标 BGM 与全局样例「${best.pattern.reusablePatternName}」音乐指纹相似,采用该样例的剪辑密度和 beat-index 事件,再套用当前样例的内容结构。` + : input.targetGrid.source === 'detected' + ? '目标 BGM 与全局库没有足够相似样例,采用用户样例 rhythm profile 的 beat-index 映射到目标 BGM。' + : '未检测到目标 BGM,采用用户样例结构和估算 beat grid 做保守节奏吸附。', + }); +} + +export function buildPacingEnvelopeBlend(input: { + sampleAvgShotSec: number; + sampleCutDensity: string; + samplePeakAt: number; + targetMusic: MusicFingerprint; + globalProfile?: RhythmProfile; + sampleWeight?: number; + globalWeight?: number; + rationaleHint?: string; +}): PacingEnvelope { + const hasGlobal = Boolean(input.globalProfile); + const weights = pacingBlendWeights({ + hasGlobal, + targetMusic: input.targetMusic, + sampleWeight: input.sampleWeight, + globalWeight: input.globalWeight, + }); + const sampleAvgShotSec = clamp(Number.isFinite(input.sampleAvgShotSec) ? input.sampleAvgShotSec : 1.5, 0.35, 4.5); + const sampleCutDensity = normalizePacingCutDensity(input.sampleCutDensity); + const samplePeakAt = clamp01(Number.isFinite(input.samplePeakAt) ? input.samplePeakAt : 0.55); + const globalPattern = input.globalProfile?.shotPattern; + const globalAvgShotSec = clamp(globalPattern?.avgShotSec ?? sampleAvgShotSec, 0.35, 4.5); + const globalCutDensity = normalizePacingCutDensity(globalPattern?.cutDensity ?? input.sampleCutDensity); + const globalPeakAt = clamp01(globalPattern?.peakAt ?? samplePeakAt); + const blendedPeakAt = round(samplePeakAt * weights.sampleWeight + globalPeakAt * weights.globalWeight); + const phases = pacingPhaseBounds(blendedPeakAt).map((phase) => { + const phaseCenter = (phase.startRatio + phase.endRatio) / 2; + const samplePhaseAvg = phaseAvgShotSec({ + avgShotSec: sampleAvgShotSec, + cutDensity: sampleCutDensity, + peakAt: samplePeakAt, + phaseRole: phase.role, + phaseCenter, + }); + const globalPhaseAvg = hasGlobal + ? phaseAvgShotSec({ + avgShotSec: globalAvgShotSec, + cutDensity: globalCutDensity, + peakAt: globalPeakAt, + phaseRole: phase.role, + phaseCenter, + }) + : samplePhaseAvg; + const blendedAvg = samplePhaseAvg * weights.sampleWeight + globalPhaseAvg * weights.globalWeight; + const musicAdjustedAvg = adjustPhaseAvgForTargetMusic(blendedAvg, phase.role, input.targetMusic); + const avgShotSec = round(clamp(musicAdjustedAvg, 0.35, 4.5)); + const source = hasGlobal ? 'blended' : 'user_sample'; + return PacingEnvelopePhase.parse({ + ...phase, + startRatio: round(phase.startRatio), + endRatio: round(phase.endRatio), + avgShotSec, + cutDensity: pacingDensityFromAvg(avgShotSec), + source, + rationale: hasGlobal + ? `当前样例 ${round(samplePhaseAvg)}s/镜 × ${weights.sampleWeight} + 全局样例 ${round(globalPhaseAvg)}s/镜 × ${weights.globalWeight}` + : `当前样例 ${round(samplePhaseAvg)}s/镜,无可用全局节奏样例。`, + }); + }); + + return PacingEnvelope.parse({ + id: `pacing_${hasGlobal ? input.globalProfile?.id ?? 'global' : 'user'}_${Math.round(blendedPeakAt * 100)}`, + source: hasGlobal ? 'blended' : 'user_sample', + sampleWeight: weights.sampleWeight, + globalWeight: weights.globalWeight, + phases, + qcAdjustmentPolicy: + 'QC Score 达标时保持当前权重;若 pacingChange/watchability 低于阈值,后续可在 ±0.12 范围内微调 sample/global 权重,但保留当前样例为主、全局样例非零的约束。', + rationale: hasGlobal + ? `PacingEnvelopeBlend 使用当前样例 pacing 为主(${weights.sampleWeight})并注入全局样例 pacing(${weights.globalWeight});${input.rationaleHint ?? '按目标 BGM 与样例库节奏共同生成快慢 envelope'}。` + : `PacingEnvelope 使用当前样例 pacing;${input.rationaleHint ?? '暂无可融合的全局样例节奏'}。`, + }); +} + +export function nearestBeatAnchor( + timeSec: number, + beatGrid: BeatGrid, + rhythmPlan?: RhythmAlignmentPlan, +): RhythmAnchor { + const nearest = nearestBeat(timeSec, beatGrid.beatsSec); + const event = nearestAlignmentEvent(timeSec, rhythmPlan); + const preferredTimeSec = event?.targetTimeSec ?? nearest.timeSec; + const source: RhythmSource = event + ? rhythmPlan?.strategy === 'global_music_match' + ? 'global_match' + : 'user_sample' + : beatGrid.source === 'detected' + ? 'target_music' + : 'estimated'; + return RhythmAnchor.parse({ + source, + beatIndex: event?.beatIndex ?? nearest.index, + phraseIndex: event?.phraseIndex ?? Math.floor((nearest.index ?? 0) / 8), + preferredTimeSec, + actualTimeSec: round(timeSec), + nearestBeatSec: nearest.timeSec, + offsetMs: Math.round((timeSec - nearest.timeSec) * 1000), + toleranceMs: rhythmPlan?.qualityTargets.cutToBeatToleranceMs ?? 160, + lockStrength: event?.strength === 'strong' ? 'hard' : 'soft', + rationale: event?.description || '按目标 BGM 最近 beat 吸附。', + }); +} + +export function rhythmCutAnchors(rhythmPlan: RhythmAlignmentPlan | undefined): number[] { + return (rhythmPlan?.events ?? []) + .filter((event) => event.eventType === 'cut' || event.eventType === 'transition') + .map((event) => event.targetTimeSec) + .filter((time, index, arr) => time > 0 && arr.findIndex((v) => Math.abs(v - time) < 0.05) === index) + .sort((a, b) => a - b); +} + +function pacingBlendWeights(input: { + hasGlobal: boolean; + targetMusic: MusicFingerprint; + sampleWeight?: number; + globalWeight?: number; +}): { sampleWeight: number; globalWeight: number } { + if (!input.hasGlobal) return { sampleWeight: 1, globalWeight: 0 }; + let sampleWeight = input.sampleWeight ?? (input.targetMusic.hasAudio ? 0.66 : 0.72); + let globalWeight = input.globalWeight ?? 1 - sampleWeight; + sampleWeight = clamp01(sampleWeight); + globalWeight = clamp01(globalWeight); + if (globalWeight <= 0) globalWeight = 0.18; + if (sampleWeight <= 0) sampleWeight = 0.62; + if (sampleWeight <= globalWeight) { + sampleWeight = 0.62; + globalWeight = 0.38; + } + const total = Math.max(0.001, sampleWeight + globalWeight); + sampleWeight /= total; + globalWeight /= total; + if (globalWeight < 0.15) { + globalWeight = 0.15; + sampleWeight = 0.85; + } + if (sampleWeight <= globalWeight) { + sampleWeight = 0.62; + globalWeight = 0.38; + } + return { sampleWeight: round(sampleWeight), globalWeight: round(globalWeight) }; +} + +function pacingPhaseBounds(peakAt: number): Array<{ role: PacingPhaseRole; startRatio: number; endRatio: number }> { + const climaxStart = clamp(peakAt - 0.13, 0.5, 0.72); + const climaxEnd = clamp(peakAt + 0.14, climaxStart + 0.14, 0.9); + const setupEnd = 0.22; + const accelerateEnd = clamp(climaxStart - 0.08, setupEnd + 0.16, climaxStart - 0.04); + return [ + { role: 'setup', startRatio: 0, endRatio: setupEnd }, + { role: 'accelerate', startRatio: setupEnd, endRatio: accelerateEnd }, + { role: 'hold', startRatio: accelerateEnd, endRatio: climaxStart }, + { role: 'climax', startRatio: climaxStart, endRatio: climaxEnd }, + { role: 'payoff', startRatio: climaxEnd, endRatio: 1 }, + ]; +} + +function phaseAvgShotSec(input: { + avgShotSec: number; + cutDensity: PacingCutDensity; + peakAt: number; + phaseRole: PacingPhaseRole; + phaseCenter: number; +}): number { + const roleFactor: Record = { + setup: 1.24, + accelerate: 0.9, + hold: 1.34, + climax: 0.62, + payoff: 1.16, + }; + const densityFactor: Record = { + low: 1.15, + medium: 1, + high: 0.86, + burst: 0.74, + }; + const distanceToPeak = Math.abs(input.phaseCenter - input.peakAt); + const peakFactor = + input.phaseRole === 'climax' || input.phaseRole === 'accelerate' + ? clamp(0.82 + distanceToPeak * 0.75, 0.82, 1.06) + : input.phaseRole === 'hold' + ? clamp(1.08 - distanceToPeak * 0.2, 0.98, 1.08) + : 1; + return clamp(input.avgShotSec * roleFactor[input.phaseRole] * densityFactor[input.cutDensity] * peakFactor, 0.35, 4.5); +} + +function adjustPhaseAvgForTargetMusic( + avgShotSec: number, + phaseRole: PacingPhaseRole, + targetMusic: MusicFingerprint, +): number { + if (!targetMusic.hasAudio) return avgShotSec; + const highOnset = targetMusic.onsetDensity >= 1.8; + const mediumOnset = targetMusic.onsetDensity >= 1.25; + if ((phaseRole === 'accelerate' || phaseRole === 'climax') && highOnset) return avgShotSec * 0.88; + if ((phaseRole === 'accelerate' || phaseRole === 'climax') && mediumOnset) return avgShotSec * 0.94; + if ((phaseRole === 'hold' || phaseRole === 'payoff') && targetMusic.energyShape === 'rising') return avgShotSec * 1.04; + return avgShotSec; +} + +function normalizePacingCutDensity(density: string | undefined): PacingCutDensity { + if (density === 'low' || density === 'medium' || density === 'high' || density === 'burst') return density; + return 'medium'; +} + +function pacingDensityFromAvg(avgShotSec: number): PacingCutDensity { + if (avgShotSec <= 0.75) return 'burst'; + if (avgShotSec <= 1.35) return 'high'; + if (avgShotSec <= 2.4) return 'medium'; + return 'low'; +} + +function mapProfileEventsToTarget( + profile: RhythmProfile, + targetGrid: BeatGrid, + targetDurationSec: number, + sourcePatternId: string, +): RhythmAlignmentEvent[] { + const targetBeats = targetGrid.beatsSec.length ? targetGrid.beatsSec : estimatedBeatsFor(targetDurationSec, targetGrid.bpm); + const sourceBeatCount = Math.max( + 1, + profile.music.beatCount, + ...profile.events.map((event) => (event.beatIndex ?? 0) + 1), + ); + const targetBeatCount = Math.max(1, targetBeats.length); + return profile.events + .map((event) => { + const mappedBeatIndex = + event.beatIndex != null + ? Math.min(targetBeatCount - 1, Math.max(0, Math.round((event.beatIndex / sourceBeatCount) * targetBeatCount))) + : Math.min(targetBeatCount - 1, Math.max(0, Math.round(event.relativeTime * (targetBeatCount - 1)))); + const beatTime = targetBeats[mappedBeatIndex] ?? event.relativeTime * targetDurationSec; + const offsetSec = Math.max(-0.18, Math.min(0.18, event.offsetMs / 1000)); + const rawTarget = event.eventType === 'cut' || event.eventType === 'transition' + ? beatTime + offsetSec + : event.relativeTime * targetDurationSec; + const targetTimeSec = clamp(rawTarget, 0, targetDurationSec); + const nearest = nearestBeat(targetTimeSec, targetBeats); + return RhythmAlignmentEvent.parse({ + ...event, + sourceTimeSec: event.timeSec, + targetTimeSec: round(event.eventType === 'cut' || event.eventType === 'transition' ? nearest.timeSec : targetTimeSec), + sourcePatternId, + beatIndex: event.beatIndex ?? mappedBeatIndex, + phraseIndex: Math.floor(mappedBeatIndex / (profile.shotPattern.phraseLengthBeats || 8)), + nearestBeatSec: nearest.timeSec, + offsetMs: Math.round((targetTimeSec - nearest.timeSec) * 1000), + confidence: event.strength === 'strong' ? 0.82 : 0.7, + }); + }) + .filter((event) => event.targetTimeSec >= 0 && event.targetTimeSec <= targetDurationSec) + .sort((a, b) => a.targetTimeSec - b.targetTimeSec) + .slice(0, 80); +} + +function cutTimesFromSample(analysis: SampleAnalysis, blueprint: VideoStructureBlueprint): number[] { + const scenes = analysis.scenes.map((scene) => scene.atSec); + if (scenes.length) return scenes; + return (blueprint.rhythmStructure.shots ?? []).map((shot) => shot.endSec); +} + +function segmentBoundaryEvents( + blueprint: VideoStructureBlueprint, + durationSec: number, + beatsSec: number[], +): RhythmEditEvent[] { + const starts = cumulativeSegmentStarts(blueprint, durationSec); + return blueprint.scriptStructure.segments.map((segment, index) => + eventForTime({ + eventType: index === 0 ? 'title' : 'caption', + timeSec: starts[index] ?? 0, + durationSec, + beatsSec, + strength: index === 0 ? 'strong' : 'medium', + segmentRole: segment.role, + description: segment.label ?? segment.intent, + }), + ); +} + +function eventForTime(opts: { + eventType: RhythmEventType; + timeSec: number; + durationSec: number; + beatsSec: number[]; + strength: 'weak' | 'medium' | 'strong'; + segmentRole?: SegmentRole; + description: string; +}): RhythmEditEvent { + const nearest = nearestBeat(opts.timeSec, opts.beatsSec); + return RhythmEditEvent.parse({ + eventType: opts.eventType, + timeSec: round(opts.timeSec), + relativeTime: clamp01(opts.timeSec / Math.max(0.001, opts.durationSec)), + beatIndex: nearest.index, + phraseIndex: nearest.index == null ? undefined : Math.floor(nearest.index / 8), + nearestBeatSec: nearest.timeSec, + offsetMs: Math.round((opts.timeSec - nearest.timeSec) * 1000), + segmentRole: opts.segmentRole, + strength: opts.strength, + description: opts.description, + }); +} + +function nearestAlignmentEvent(timeSec: number, rhythmPlan?: RhythmAlignmentPlan): RhythmAlignmentEvent | undefined { + const events = rhythmPlan?.events.filter((event) => event.eventType === 'cut' || event.eventType === 'transition') ?? []; + if (!events.length) return undefined; + const nearest = events.reduce((best, event) => + Math.abs(event.targetTimeSec - timeSec) < Math.abs(best.targetTimeSec - timeSec) ? event : best, + ); + return Math.abs(nearest.targetTimeSec - timeSec) <= (rhythmPlan?.qualityTargets.cutToBeatToleranceMs ?? 160) / 1000 + 0.12 + ? nearest + : undefined; +} + +function estimatedBeatsFor(durationSec: number, bpm: number): number[] { + const step = 60 / bpm; + const beats: number[] = []; + for (let t = 0; t <= durationSec + step / 2; t += step) beats.push(round(Math.min(durationSec, t))); + return Array.from(new Set(beats)); +} + +function intervalsFromTimes(times: number[]): number[] { + return times.slice(1).map((time, i) => round(time - times[i])).filter((interval) => interval > 0); +} + +function cumulativeSegmentTimes(blueprint: VideoStructureBlueprint, durationSec: number): number[] { + let cursor = 0; + return blueprint.scriptStructure.segments.map((segment) => { + cursor = round(cursor + segment.durationRatio * durationSec); + return cursor; + }); +} + +function cumulativeSegmentStarts(blueprint: VideoStructureBlueprint, durationSec: number): number[] { + let cursor = 0; + return blueprint.scriptStructure.segments.map((segment) => { + const start = cursor; + cursor = round(cursor + segment.durationRatio * durationSec); + return round(start); + }); +} + +function cumulativeRatio(segments: Array<{ durationRatio: number }>, index: number): number { + return segments.slice(0, index).reduce((sum, segment) => sum + segment.durationRatio, 0); +} + +function nearestBeat(timeSec: number, beatsSec: number[]): { timeSec: number; index?: number } { + if (!beatsSec.length) return { timeSec: round(timeSec), index: undefined }; + let bestIndex = 0; + for (let i = 1; i < beatsSec.length; i += 1) { + if (Math.abs(beatsSec[i] - timeSec) < Math.abs(beatsSec[bestIndex] - timeSec)) bestIndex = i; + } + return { timeSec: beatsSec[bestIndex], index: bestIndex }; +} + +function stabilityFromIntervals(intervals: number[], fallbackSec: number): number { + if (intervals.length < 2) return 0.45; + const median = medianNumber(intervals) ?? fallbackSec; + const drift = intervals.reduce((sum, interval) => sum + Math.abs(interval - median), 0) / intervals.length; + return clamp01(1 - drift / Math.max(0.001, median)); +} + +function medianNumber(values: number[]): number | undefined { + const finite = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b); + if (!finite.length) return undefined; + return finite[Math.floor(finite.length / 2)]; +} + +function energyShapeForPeak(peakAt: number): MusicEnergyShape { + if (peakAt < 0.33) return 'front_loaded'; + if (peakAt > 0.72) return 'late_peak'; + if (peakAt > 0.58) return 'rising'; + return 'mid_peak'; +} + +function inferDownbeatsFromBeats(beatsSec: number[]): number[] { + return beatsSec.filter((_, index) => index % 4 === 0).map(round); +} + +function inferPhraseBoundariesFromBeats(beatsSec: number[]): number[] { + return beatsSec.filter((_, index) => index % 8 === 0).map(round); +} + +function inferMusicSections( + durationSec: number, + downbeatsSec: number[], + energyShape: MusicEnergyShape, +): MusicSection[] { + if (durationSec <= 0) return []; + const dropStartRatio = energyShape === 'late_peak' ? 0.68 : energyShape === 'front_loaded' ? 0.18 : 0.55; + const introEnd = round(Math.min(durationSec, Math.max(1.2, durationSec * 0.18))); + const dropStart = round(Math.min(durationSec - 0.8, Math.max(introEnd, durationSec * dropStartRatio))); + const dropEnd = round(Math.min(durationSec, dropStart + Math.max(1.2, durationSec * 0.18))); + const sections = [ + { + startSec: 0, + endSec: introEnd, + kind: 'intro' as const, + confidence: 0.42, + downbeatsSec: downbeatsSec.filter((time) => time >= 0 && time < introEnd), + }, + { + startSec: introEnd, + endSec: dropStart, + kind: 'build' as const, + confidence: 0.38, + downbeatsSec: downbeatsSec.filter((time) => time >= introEnd && time < dropStart), + }, + { + startSec: dropStart, + endSec: dropEnd, + kind: 'drop' as const, + confidence: 0.38, + downbeatsSec: downbeatsSec.filter((time) => time >= dropStart && time < dropEnd), + }, + { + startSec: dropEnd, + endSec: durationSec, + kind: 'outro' as const, + confidence: 0.35, + downbeatsSec: downbeatsSec.filter((time) => time >= dropEnd && time <= durationSec), + }, + ].filter((section) => section.endSec - section.startSec > 0.2); + return MusicSection.array().parse(sections); +} + +function captionStrategyFor(blueprint: VideoStructureBlueprint): string { + const density = blueprint.packagingStructure?.subtitleDensity ?? 'medium'; + if (density === 'dense') return '字幕高频跟随切镜或信息点,优先落在下三分之一。'; + if (density === 'sparse') return '只在 hook、重点和收尾处出现字幕。'; + return '字幕按段落信息点出现,避免漂浮徽章。'; +} + +function densityName(density: string): string { + return density === 'high' ? '快节奏' : density === 'low' ? '慢节奏' : '中等节奏'; +} + +function similarityByRelativeDistance(a: number | undefined, b: number | undefined, tolerance: number): number { + if (!a || !b) return 0.45; + const distance = Math.abs(a - b) / Math.max(a, b, 0.001); + return clamp01(1 - distance / tolerance); +} + +function confidenceWeight(confidence: MusicFingerprint['confidence']): number { + if (confidence === 'high') return 1; + if (confidence === 'medium') return 0.75; + if (confidence === 'low') return 0.45; + return 0; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function clamp01(value: number): number { + return clamp(value, 0, 1); +} + +function round(n: number): number { + return Number(n.toFixed(3)); +} diff --git a/apps/api/src/core/sample.ts b/apps/api/src/core/sample.ts index 1a87bd8..18754aa 100644 --- a/apps/api/src/core/sample.ts +++ b/apps/api/src/core/sample.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { Evidence } from './explain'; +import { TemplateProfile } from './template'; /** 样例视频的机器元数据。 */ export const SampleMetadata = z.object({ @@ -27,6 +28,14 @@ export const Keyframe = z.object({ }); export type Keyframe = z.infer; +export const TranscriptCue = z.object({ + startSec: z.number().min(0), + endSec: z.number().min(0), + text: z.string(), + confidence: z.number().min(0).max(1).optional(), +}).refine((cue) => cue.endSec > cue.startSec, { message: 'endSec 必须大于 startSec' }); +export type TranscriptCue = z.infer; + /** * 样例解析结果(ParseAgent 输出)。 * 全部为机器证据,作为 StructureAgent 抽象结构蓝图的输入。 @@ -37,6 +46,10 @@ export const SampleAnalysis = z.object({ metadata: SampleMetadata, scenes: z.array(SceneCut), shotCount: z.number().int(), + /** 样例里的模板包装 / 内部运动 / 音频 onset 信号,供 Sample Learning 与 Remotion 迁移。 */ + templateProfile: TemplateProfile.optional(), + /** ASR 带时间戳转写;当前可为空,后续 ParseAgent 接 ASR endpoint 后写入。 */ + transcriptCues: z.array(TranscriptCue).default([]), keyframes: z.array(Keyframe), coverPath: z.string(), evidence: z.array(Evidence).default([]), diff --git a/apps/api/src/core/sampleLearning.ts b/apps/api/src/core/sampleLearning.ts new file mode 100644 index 0000000..72d8ad6 --- /dev/null +++ b/apps/api/src/core/sampleLearning.ts @@ -0,0 +1,502 @@ +import { z } from 'zod'; +import { PackagingStructure } from './blueprint'; +import { AssetTag, MediaType, SegmentRole, StoryFunction, VideoGenre } from './enums'; +import { Evidence } from './explain'; +import { MusicFingerprint, RhythmProfile } from './rhythm'; +import { TemplateProfile } from './template'; +import { CardAnimationPreset, MotionPreset, TransitionPreset } from './timeline'; + +export const LearnedSegmentPattern = z.object({ + role: SegmentRole, + label: z.string().optional(), + durationRatio: z.number().min(0).max(1), + intent: z.string(), + copyPattern: z.string(), + watchingPurpose: z.string(), +}); +export type LearnedSegmentPattern = z.infer; + +export const LearnedPacingPattern = z.object({ + durationSec: z.number().positive(), + shotCount: z.number().int().nonnegative(), + avgShotSec: z.number().positive(), + cutDensity: z.enum(['low', 'medium', 'high']), + peakAt: z.number().min(0).max(1), + beatHints: z.array(z.string()).default([]), +}); +export type LearnedPacingPattern = z.infer; + +export const LearnedScriptStructureDimension = z.object({ + formula: z.string(), + segmentCount: z.number().int().nonnegative(), + segments: z.array(LearnedSegmentPattern).default([]), + notes: z.array(z.string()).default([]), +}); +export type LearnedScriptStructureDimension = z.infer; + +export const LearnedShotRhythmDimension = LearnedPacingPattern.extend({ + rhythmNotes: z.array(z.string()).default([]), +}); +export type LearnedShotRhythmDimension = z.infer; + +export const LearnedSubtitleStyleDimension = z.object({ + density: z.string(), + placement: z.string().default('unknown'), + typography: z.string().default('unknown'), + animation: z.string().default('unknown'), + notes: z.array(z.string()).default([]), +}); +export type LearnedSubtitleStyleDimension = z.infer; + +export const LearnedVisualPackagingDimension = z.object({ + titleBarStyle: z.string().optional(), + stickerUsage: z.string().optional(), + coverStyle: z.string().optional(), + overlayStyle: z.string().default('unknown'), + notes: z.array(z.string()).default([]), +}); +export type LearnedVisualPackagingDimension = z.infer; + +export const LearnedTransitionDimension = z.object({ + style: z.string(), + frequency: z.string().default('unknown'), + notableTransitions: z.array(z.string()).default([]), + executableTechniques: z.array(z.object({ + id: z.string(), + name: z.string(), + triggerCondition: z.string(), + appliesToStoryFunctions: z.array(StoryFunction).default([]), + requiredRenderer: z.enum(['remotion', 'ffmpeg', 'both']), + motionPreset: MotionPreset.optional(), + transitionPreset: TransitionPreset.optional(), + cardAnimationPreset: CardAnimationPreset.optional(), + implementationNotes: z.string(), + })).default([]), +}); +export type LearnedTransitionDimension = z.infer; + +export const LearnedBgmSyncDimension = z.object({ + hasAudio: z.boolean(), + beatHints: z.array(z.string()).default([]), + syncStrategy: z.string(), + confidence: z.enum(['none', 'low', 'medium', 'high']).default('low'), + limitations: z.array(z.string()).default([]), +}); +export type LearnedBgmSyncDimension = z.infer; + +export const StorySkeletonPattern = z.object({ + arcType: z.string(), + segmentRoles: z.array(SegmentRole).default([]), + emotionalCurve: z.array(z.string()).default([]), + hookStyle: z.string(), + turnOrProofStyle: z.string(), + payoffStyle: z.string(), + requiredStoryFunctions: z.array(StoryFunction).default([]), + bestForGenres: z.array(VideoGenre).default([]), + assetRequirements: z.array(AssetTag).default([]), +}); +export type StorySkeletonPattern = z.infer; + +export const EditingTechniquePattern = z.object({ + id: z.string(), + name: z.string(), + triggerCondition: z.string(), + appliesToStoryFunction: z.array(StoryFunction).default([]), + appliesToAssetType: z.array(MediaType).default([]), + appliesToVisualCluster: z.array(z.string()).default([]), + motionPreset: MotionPreset.optional(), + transitionPreset: TransitionPreset.optional(), + beatPlacement: z.string().default('free'), + cardAnimationPreset: CardAnimationPreset.optional(), + intensity: z.enum(['low', 'medium', 'high']).default('medium'), + avoidWhen: z.array(z.string()).default([]), + requiredRenderer: z.enum(['remotion', 'ffmpeg', 'both']), + implementationNotes: z.string(), +}); +export type EditingTechniquePattern = z.infer; + +export const PackagingPattern = z.object({ + titleBarStyle: z.string().optional(), + stickerUsage: z.string().optional(), + coverStyle: z.string().optional(), + overlayStyle: z.string().default('unknown'), + cardAnimationPreset: CardAnimationPreset.optional(), + implementationNotes: z.string().default('包装样式来自样例结构抽象;渲染层按 card preset 尽量表达。'), +}); +export type PackagingPattern = z.infer; + +export const BgmSyncPattern = z.object({ + beatPlacement: z.string(), + syncStrategy: z.string(), + confidence: z.enum(['none', 'low', 'medium', 'high']).default('low'), + limitations: z.array(z.string()).default([]), +}); +export type BgmSyncPattern = z.infer; + +export const LearnedVideoDimensions = z.object({ + scriptStructure: LearnedScriptStructureDimension, + shotRhythm: LearnedShotRhythmDimension, + subtitleStyle: LearnedSubtitleStyleDimension, + visualPackaging: LearnedVisualPackagingDimension, + transitions: LearnedTransitionDimension, + bgmSync: LearnedBgmSyncDimension, +}); +export type LearnedVideoDimensions = z.infer; + +export const DEFAULT_LEARNED_VIDEO_DIMENSIONS: LearnedVideoDimensions = { + scriptStructure: { + formula: 'unknown', + segmentCount: 0, + segments: [], + notes: ['旧样例记录未显式保存脚本结构维度。'], + }, + shotRhythm: { + durationSec: 1, + shotCount: 0, + avgShotSec: 1, + cutDensity: 'medium', + peakAt: 0.5, + beatHints: [], + rhythmNotes: ['旧样例记录未显式保存镜头节奏维度。'], + }, + subtitleStyle: { + density: 'unknown', + placement: 'unknown', + typography: 'unknown', + animation: 'unknown', + notes: ['旧样例记录未显式保存字幕样式维度。'], + }, + visualPackaging: { + overlayStyle: 'unknown', + notes: ['旧样例记录未显式保存画面包装维度。'], + }, + transitions: { + style: 'unknown', + frequency: 'unknown', + notableTransitions: [], + executableTechniques: [], + }, + bgmSync: { + hasAudio: false, + beatHints: [], + syncStrategy: 'unknown', + confidence: 'none', + limitations: ['旧样例记录未显式保存 BGM 卡点维度。'], + }, +}; + +export const LearnedSlotNeed = z.object({ + slotId: z.string(), + segmentRole: SegmentRole, + requiredAssetTypes: z.array(AssetTag).min(1), + minDurationSec: z.number().positive().optional(), + optional: z.boolean().default(false), +}); +export type LearnedSlotNeed = z.infer; + +export const LearnedSampleSource = z.object({ + filename: z.string(), + /** 原始样例视频路径;用于重启后仍能把全局样例可靠恢复为 ReferenceAsset。 */ + sourcePath: z.string().optional(), + durationSec: z.number().positive(), + aspectRatio: z.string(), + shotCount: z.number().int().nonnegative(), +}); +export type LearnedSampleSource = z.infer; + +export const LearnedSampleScope = z.object({ + storySkeleton: z.boolean().default(true), + editingTechniques: z.boolean().default(true), + packagingStyle: z.boolean().default(true), + bgmSync: z.boolean().default(true), +}); +export type LearnedSampleScope = z.infer; + +export const RejectedLearningTechnique = z.object({ + name: z.string(), + reason: z.string(), + userMessage: z.string(), +}); +export type RejectedLearningTechnique = z.infer; + +export const PatternDepth = z.enum([ + 'full_story', + 'story_candidate', + 'thin_pattern', + 'template_or_editing_only', +]); +export type PatternDepth = z.infer; + +export const CtaType = z.enum([ + 'none', + 'platform_follow', + 'search_account', + 'tutorial_get', + 'purchase', + 'booking', + 'trial', + 'lead_capture', + 'generic_next_action', +]); +export type CtaType = z.infer; + +export const CommercialUsefulness = z.enum(['strong', 'medium', 'weak', 'not_recommended']); +export type CommercialUsefulness = z.infer; + +export const RecommendedPatternUse = z.enum([ + 'primary_story', + 'secondary_story', + 'editing_only', + 'template_only', + 'learn_only', +]); +export type RecommendedPatternUse = z.infer; + +export const VisualBridgePolicy = z.object({ + use: z.enum(['allowed', 'learn_only', 'blocked']).default('learn_only'), + reasons: z.array(z.string()).default([]), +}); +export type VisualBridgePolicy = z.infer; + +export const LearningQualityTags = z.object({ + patternDepth: PatternDepth, + ctaType: CtaType, + visualBridgePolicy: VisualBridgePolicy, + commercialUsefulness: CommercialUsefulness, + recommendedUse: RecommendedPatternUse, + warnings: z.array(z.string()).default([]), +}); +export type LearningQualityTags = z.infer; + +export const DEFAULT_LEARNING_QUALITY_TAGS: LearningQualityTags = { + patternDepth: 'story_candidate', + ctaType: 'generic_next_action', + visualBridgePolicy: { + use: 'learn_only', + reasons: ['旧样例记录未经过视觉桥接质检,默认只学习结构 / 剪辑方法。'], + }, + commercialUsefulness: 'medium', + recommendedUse: 'secondary_story', + warnings: [], +}; + +export const LearnedSamplePattern = z.object({ + id: z.string(), + scope: z.enum(['global', 'project']).default('global'), + projectId: z.string().optional(), + sourceSampleId: z.string(), + name: z.string(), + summary: z.string(), + videoGenre: VideoGenre, + tags: z.array(z.string()).default([]), + qualityTags: LearningQualityTags.default(DEFAULT_LEARNING_QUALITY_TAGS), + learnScope: LearnedSampleScope.default({ + storySkeleton: true, + editingTechniques: true, + packagingStyle: true, + bgmSync: true, + }), + reusablePatternName: z.string(), + formula: z.string(), + source: LearnedSampleSource, + segments: z.array(LearnedSegmentPattern).min(1), + pacing: LearnedPacingPattern, + packaging: PackagingStructure.optional(), + storySkeleton: StorySkeletonPattern.optional(), + editingTechniques: z.array(EditingTechniquePattern).default([]), + packagingPattern: PackagingPattern.optional(), + bgmSyncPattern: BgmSyncPattern.optional(), + /** 音乐指纹用于不同 BGM 时从全局样例库检索相似剪法。 */ + musicFingerprint: MusicFingerprint.optional(), + /** 样例剪辑事件 profile:cut/title/caption 与 beat/phrase 的关系。 */ + rhythmProfile: RhythmProfile.optional(), + /** 样例模板包装 / 画幅 / 内部运动 / 遮罩转场 profile,Remotion 可按它复刻视觉框架。 */ + templateProfile: TemplateProfile.optional(), + learnedDimensions: LearnedVideoDimensions.default(DEFAULT_LEARNED_VIDEO_DIMENSIONS), + slotNeeds: z.array(LearnedSlotNeed).default([]), + evidence: z.array(Evidence).default([]), + rationale: z.string().default(''), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type LearnedSamplePattern = z.infer; +export type LearnedSamplePatternInput = z.input; + +export const SampleLearningDraft = z.object({ + id: z.string(), + scope: z.enum(['global', 'project']).default('global'), + projectId: z.string().optional(), + sampleId: z.string(), + status: z.literal('draft'), + learnedThings: z.object({ + summary: z.string(), + reusablePatternName: z.string(), + formula: z.string(), + keyTakeaways: z.array(z.string()).default([]), + storageNotes: z.array(z.string()).default([]), + risks: z.array(z.string()).default([]), + rejectedTechniques: z.array(RejectedLearningTechnique).default([]), + recommendation: z.object({ + suggestedMode: z.enum(['storySkeleton', 'editingTechniques', 'both']), + reasons: z.array(z.string()).default([]), + }), + }), + dbData: LearnedSamplePattern, +}); +export type SampleLearningDraft = z.infer; + +export function withInferredLearningQualityTags(pattern: LearnedSamplePattern): LearnedSamplePattern { + if (!usesDefaultLearningQualityTags(pattern.qualityTags)) return pattern; + return { + ...pattern, + qualityTags: inferLearningQualityTagsFromPattern(pattern), + }; +} + +function usesDefaultLearningQualityTags(tags: LearningQualityTags): boolean { + return ( + tags.patternDepth === DEFAULT_LEARNING_QUALITY_TAGS.patternDepth && + tags.ctaType === DEFAULT_LEARNING_QUALITY_TAGS.ctaType && + tags.visualBridgePolicy.use === DEFAULT_LEARNING_QUALITY_TAGS.visualBridgePolicy.use && + tags.commercialUsefulness === DEFAULT_LEARNING_QUALITY_TAGS.commercialUsefulness && + tags.recommendedUse === DEFAULT_LEARNING_QUALITY_TAGS.recommendedUse && + tags.warnings.length === 0 + ); +} + +function inferLearningQualityTagsFromPattern(pattern: LearnedSamplePattern): LearningQualityTags { + const text = [ + pattern.name, + pattern.summary, + pattern.reusablePatternName, + pattern.formula, + pattern.videoGenre, + ...pattern.tags, + ...pattern.segments.flatMap((segment) => [ + segment.label, + segment.intent, + segment.copyPattern, + segment.watchingPurpose, + ]), + pattern.packaging?.titleBarStyle, + pattern.packaging?.stickerUsage, + pattern.packaging?.transitionStyle, + pattern.packaging?.coverStyle, + pattern.packagingPattern?.titleBarStyle, + pattern.packagingPattern?.stickerUsage, + pattern.packagingPattern?.coverStyle, + ...pattern.slotNeeds.flatMap((slot) => slot.requiredAssetTypes), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + const patternDepth = inferStoredPatternDepth(pattern); + const ctaType = inferStoredCtaType(text, pattern); + const visualBridgePolicy = inferStoredVisualBridgePolicy(text, pattern, patternDepth); + const commercialUsefulness = inferStoredCommercialUsefulness(pattern, ctaType, patternDepth); + const recommendedUse = inferStoredRecommendedUse(pattern, patternDepth, commercialUsefulness); + const warnings = [ + ...(patternDepth === 'template_or_editing_only' + ? ['这条样例镜头 / 时长太薄,更适合学剪辑、模板或视觉桥接,不适合作为主故事骨架。'] + : []), + ...(patternDepth === 'thin_pattern' + ? ['这条样例故事结构偏薄,建议作为 secondary story 或剪辑参考。'] + : []), + ...(ctaType === 'platform_follow' || ctaType === 'search_account' || ctaType === 'tutorial_get' + ? ['检测到平台关注 / 搜索 / 教程获取类 CTA,迁移时应替换为目标业务自己的下一步行动。'] + : []), + ...(commercialUsefulness === 'weak' || commercialUsefulness === 'not_recommended' + ? ['商业转化用途偏弱,不建议在产品 / 店铺 / App 转化项目中作为主骨架。'] + : []), + ...(visualBridgePolicy.use !== 'allowed' + ? [`视觉桥接限制:${visualBridgePolicy.reasons.join(';') || '默认只学习方法,不复用画面。'}`] + : []), + ]; + return { patternDepth, ctaType, visualBridgePolicy, commercialUsefulness, recommendedUse, warnings }; +} + +function inferStoredPatternDepth(pattern: LearnedSamplePattern): LearningQualityTags['patternDepth'] { + const segmentCount = pattern.segments.length || pattern.learnedDimensions.scriptStructure.segmentCount; + const roles = new Set(pattern.segments.map((segment) => segment.role)); + const hasHook = roles.has('hook'); + const hasClosing = roles.has('closing'); + if (pattern.source.shotCount <= 2 || pattern.source.durationSec < 12) return 'template_or_editing_only'; + if (segmentCount < 3 || !hasHook || !hasClosing || pattern.source.shotCount < 5 || pattern.source.durationSec < 15) { + return 'thin_pattern'; + } + if (pattern.source.durationSec <= 45 && pattern.source.shotCount >= 8 && segmentCount >= 4) return 'full_story'; + return 'story_candidate'; +} + +function inferStoredCtaType(text: string, pattern: LearnedSamplePattern): LearningQualityTags['ctaType'] { + const hasClosing = pattern.segments.some((segment) => segment.role === 'closing'); + if (/(关注|粉丝|账号|主页|平台|搜索|搜一搜|同款|获取教程|教程获取|领取教程|私信|引流|follow|subscribe|account)/i.test(text)) { + return 'platform_follow'; + } + if (/(教程|get tutorial|tutorial)/i.test(text)) return 'tutorial_get'; + if (/(预约|到店|订座|预订|booking|reserve|book now)/i.test(text)) return 'booking'; + if (/(试用|免费体验|开始体验|立即体验|trial|try now|start free)/i.test(text)) return 'trial'; + if (/(咨询|留资|表单|加微信|私域|lead|contact us)/i.test(text)) return 'lead_capture'; + if (/(购买|下单|领券|优惠|报价|限时|加入购物车|buy|purchase|shop now|order)/i.test(text)) return 'purchase'; + return hasClosing ? 'generic_next_action' : 'none'; +} + +function inferStoredVisualBridgePolicy( + text: string, + pattern: LearnedSamplePattern, + patternDepth: LearningQualityTags['patternDepth'], +): LearningQualityTags['visualBridgePolicy'] { + const reasons: string[] = []; + const slotTags = pattern.slotNeeds.flatMap((slot) => slot.requiredAssetTypes); + if (slotTags.includes('talking_head') || /(真人|人物|人像|游客|自拍|出镜|口播|脸|女孩|男孩|路人|互动|person|people|face|selfie|tourist)/i.test(text)) { + reasons.push('包含真人 / 人像 / 游客 / 口播风险画面'); + } + if (/(水印|平台|账号|用户名|搜索|关注|logo|标识|官方|教程获取|获取教程|watermark)/i.test(text)) { + reasons.push('包含平台水印 / 账号标识 / 搜索引导'); + } + if (slotTags.some((tag) => tag === 'product_closeup' || tag === 'usage_demo' || tag === 'comparison')) { + reasons.push('包含产品、动作细节或结果证明类画面,不应用参考画面伪造'); + } + if (reasons.length) return { use: 'blocked', reasons }; + if (patternDepth === 'template_or_editing_only') { + return { use: 'learn_only', reasons: ['样例过薄,默认只学习模板 / 剪辑语言;如需桥接应由用户显式确认。'] }; + } + return { use: 'allowed', reasons: ['未检测到人物、水印、平台账号或事实证明风险,可作为短氛围 / 尺度 / 转场桥接候选。'] }; +} + +function inferStoredCommercialUsefulness( + pattern: LearnedSamplePattern, + ctaType: LearningQualityTags['ctaType'], + patternDepth: LearningQualityTags['patternDepth'], +): LearningQualityTags['commercialUsefulness'] { + const slotTags = new Set(pattern.slotNeeds.flatMap((slot) => slot.requiredAssetTypes)); + const hasProofVisual = slotTags.has('comparison') || slotTags.has('usage_demo'); + const hasProductVisual = slotTags.has('product_closeup') || hasProofVisual; + const conversionCta = ctaType === 'purchase' || ctaType === 'booking' || ctaType === 'trial' || ctaType === 'lead_capture'; + if (patternDepth === 'template_or_editing_only' && (ctaType === 'platform_follow' || ctaType === 'search_account' || ctaType === 'tutorial_get')) { + return 'not_recommended'; + } + if (conversionCta && (pattern.videoGenre === 'product' || hasProductVisual || pattern.videoGenre === 'tutorial')) { + return 'strong'; + } + if (pattern.videoGenre === 'product' || hasProofVisual) return 'medium'; + if (ctaType === 'platform_follow' || ctaType === 'search_account' || ctaType === 'tutorial_get') return 'weak'; + if (pattern.videoGenre === 'showcase' || pattern.videoGenre === 'vlog') return 'weak'; + return 'medium'; +} + +function inferStoredRecommendedUse( + pattern: LearnedSamplePattern, + patternDepth: LearningQualityTags['patternDepth'], + commercialUsefulness: LearningQualityTags['commercialUsefulness'], +): LearningQualityTags['recommendedUse'] { + if (patternDepth === 'template_or_editing_only') { + return pattern.templateProfile ? 'template_only' : 'editing_only'; + } + if (commercialUsefulness === 'not_recommended') return 'learn_only'; + if (patternDepth === 'thin_pattern') return 'secondary_story'; + if (patternDepth === 'full_story' && (commercialUsefulness === 'strong' || pattern.videoGenre === 'product')) { + return 'primary_story'; + } + return 'secondary_story'; +} diff --git a/apps/api/src/core/semanticCopy.ts b/apps/api/src/core/semanticCopy.ts new file mode 100644 index 0000000..28213a9 --- /dev/null +++ b/apps/api/src/core/semanticCopy.ts @@ -0,0 +1,44 @@ +export interface SemanticCopyContext { + topic: string; + sellingPoints?: string[]; + assetSummaries?: string[]; +} + +export interface SanitizedCopy { + text: string; + changed: boolean; + removedTerms: string[]; +} + +const SAMPLE_RESIDUE_TERMS = [ + '复古电车', + '限定路牌', + '电车', + '路牌', + '下单', + '购买链接', + '扫码', +]; + +export function sanitizeViewerCopy(text: string, ctx: SemanticCopyContext): SanitizedCopy { + const normalized = text.trim(); + if (!normalized) return { text: '', changed: false, removedTerms: [] }; + + const allowedText = [ctx.topic, ...(ctx.sellingPoints ?? []), ...(ctx.assetSummaries ?? [])] + .join(' ') + .toLowerCase(); + const removedTerms = SAMPLE_RESIDUE_TERMS.filter((term) => normalized.includes(term) && !allowedText.includes(term.toLowerCase())); + if (!removedTerms.length) return { text: normalized, changed: false, removedTerms: [] }; + + return { + text: fallbackCopy(ctx), + changed: true, + removedTerms, + }; +} + +function fallbackCopy(ctx: SemanticCopyContext): string { + const point = ctx.sellingPoints?.find((value) => value.trim())?.trim(); + if (point && point !== ctx.topic) return `${ctx.topic},看这个关键看点`; + return `${ctx.topic},先看这个关键瞬间`; +} diff --git a/apps/api/src/core/slot.ts b/apps/api/src/core/slot.ts index 59c2113..a155e30 100644 --- a/apps/api/src/core/slot.ts +++ b/apps/api/src/core/slot.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; -import { AssetTag, FillKind, FillTrack, MediaType, SegmentRole } from './enums'; +import { AssetTag, FillKind, FillTrack, MediaType, SegmentRole, StoryFunction, VisualFunction } from './enums'; /** 结构槽位:某段落 / 镜头需要什么类型的素材才能成立。 */ export const StructureSlot = z.object({ id: z.string(), segmentRole: SegmentRole, requiredAssetTypes: z.array(AssetTag).min(1), + /** 该槽位真正需要解决的视觉叙事功能;避免 b_roll 通吃所有镜头。 */ + requiredVisualFunctions: z.array(VisualFunction).optional(), minDurationSec: z.number().positive().optional(), optional: z.boolean().default(false), }); @@ -16,12 +18,58 @@ export const TaggedAsset = z.object({ id: z.string(), mediaType: MediaType, assetTags: z.array(AssetTag).default([]), + /** 素材可承担的叙事职责;用于 story-aware matching,避免所有视觉素材都退化成 b_roll。 */ + storyRoles: z.array(StoryFunction).optional(), + /** 该素材更适合放在新片哪个叙事位置。 */ + narrativeUse: StoryFunction.optional(), + /** 该素材能承担的通用视觉叙事功能。 */ + visualFunctions: z.array(VisualFunction).optional(), + /** 粗略景别:用于控制近景 / 特写素材不要承担完整叙事。 */ + shotScale: z.enum(['wide', 'medium', 'close', 'macro']).optional(), + /** 原始素材画幅,用于横屏样例 + 竖屏素材的模板兼容判断。 */ + aspectRatio: z.string().optional(), + /** 画面情绪 / 氛围标签,先保持字符串,后续可从样例库学习具体词汇。 */ + visualMood: z.array(z.string()).optional(), + /** 视觉场景簇:用于把相似湖水 / 天空 / 人像等素材作为同一类重复来源处理。 */ + visualClusterId: z.string().optional(), + /** 粗粒度镜头质量评分;可由 CV/VLM/人工输入,当前规则迁移会参与素材排序。 */ + qualityScore: z.number().min(0).max(1).optional(), + /** 视频素材推荐取用的高光窗口;render 取段时优先使用高分窗口。 */ + highlightWindows: z.array( + z.object({ + startSec: z.number().min(0), + endSec: z.number().min(0), + score: z.number().min(0).max(1), + reason: z.string().optional(), + }).refine((window) => window.endSec > window.startSec, { message: 'endSec 必须大于 startSec' }), + ).optional(), + /** 主体安全裁切提示;后续可由 face/product detector 写入。 */ + safeCropPreset: z.enum(['center', 'top', 'bottom', 'left', 'right', 'closeup']).optional(), durationSec: z.number().positive().optional(), + /** video/audio 素材是否含可用音轨;未知时保持 undefined,上传路径会尽量探测。 */ + hasAudio: z.boolean().optional(), + /** 上传时通过 FFmpeg volumedetect 得到的平均音量;用于跳过静音 AAC 外壳。 */ + audioMeanVolumeDb: z.number().optional(), + /** 上传时通过 FFmpeg volumedetect 得到的峰值音量;过低时不作为自动 BGM。 */ + audioMaxVolumeDb: z.number().optional(), + /** 文件有音频流但近似静音时置 true。 */ + silentAudioRisk: z.boolean().optional(), + /** 用户在素材页明确指定该素材作为成片 BGM。 */ + isBgm: z.boolean().optional(), confidence: z.number().min(0).max(1), summary: z.string(), }); export type TaggedAsset = z.infer; +/** 可低优先级复用的参考素材:来自当前样例视频或已学习优质样例视频。 */ +export const ReferenceAsset = TaggedAsset.extend({ + sourceRole: z.enum(['current_sample', 'learned_sample']), + sourceSampleId: z.string().optional(), + patternId: z.string().optional(), + sourcePath: z.string().optional(), +}); +export type ReferenceAsset = z.infer; + /** 缺口报告:缺什么、影响哪段、推荐什么补全策略。 */ export const Gap = z.object({ slotId: z.string(), @@ -36,8 +84,12 @@ export const FillArtifact = z.object({ id: z.string(), slotId: z.string(), kind: FillKind, - /** 文件路径或生成 job 引用。 */ + /** 文件路径或生成 job 引用;不要把内部 slotId / shotId / debug reason 编进观众可见文本。 */ source: z.string(), + /** 最终观众可见的文字卡 / 包装卡文案。 */ + displayText: z.string().optional(), + /** 给 UI / debug trace 使用的内部说明,不参与渲染。 */ + debugLabel: z.string().optional(), track: FillTrack, startSec: z.number().min(0), endSec: z.number().min(0), diff --git a/apps/api/src/core/template.ts b/apps/api/src/core/template.ts new file mode 100644 index 0000000..ecc8019 --- /dev/null +++ b/apps/api/src/core/template.ts @@ -0,0 +1,120 @@ +import { z } from 'zod'; + +export const TemplateLayoutPreset = z.enum([ + 'full_bleed', + 'cinematic_matte', + 'camera_carousel', + 'film_viewfinder_carousel', + 'letterbox_frame', + 'split_panel', + 'unknown', +]); +export type TemplateLayoutPreset = z.infer; + +export const TemplateMotionKind = z.enum([ + 'internal_motion', + 'mask_reveal', + 'viewport_slide', + 'carousel_slide', + 'asset_swap', + 'caption_pop', + 'audio_onset', +]); +export type TemplateMotionKind = z.infer; + +export const TemplateMotionDirection = z.enum(['left', 'right', 'up', 'down', 'in', 'out', 'mixed', 'unknown']); +export type TemplateMotionDirection = z.infer; + +export const TemplateViewport = z.object({ + aspectRatio: z.string().default('16:9'), + x: z.number().min(0).max(1).default(0), + y: z.number().min(0).max(1).default(0), + width: z.number().min(0).max(1).default(1), + height: z.number().min(0).max(1).default(1), +}); +export type TemplateViewport = z.infer; + +export const TemplateAudioOnset = z.object({ + timeSec: z.number().min(0), + relativeTime: z.number().min(0).max(1), + strength: z.enum(['weak', 'medium', 'strong']).default('medium'), + energyDb: z.number().optional(), +}); +export type TemplateAudioOnset = z.infer; + +export const TemplateMotionEvent = z.object({ + kind: TemplateMotionKind, + timeSec: z.number().min(0), + relativeTime: z.number().min(0).max(1), + strength: z.enum(['weak', 'medium', 'strong']).default('medium'), + direction: TemplateMotionDirection.default('unknown'), + nearestOnsetSec: z.number().min(0).optional(), + description: z.string().default(''), +}); +export type TemplateMotionEvent = z.infer; + +export const TemplateProfile = z.object({ + id: z.string(), + source: z.enum(['user_sample', 'global_sample']).default('user_sample'), + durationSec: z.number().positive(), + sourceAspect: z.string(), + targetCanvasAspect: z.string().default('9:16'), + layoutPreset: TemplateLayoutPreset.default('unknown'), + frameStyle: z.object({ + backgroundColor: z.string().default('#050505'), + matte: z.boolean().default(false), + roundedMask: z.boolean().default(false), + borderColor: z.string().optional(), + labelStyle: z.enum(['none', 'tiny_tech', 'film_code', 'camera_ui']).default('none'), + viewport: TemplateViewport.optional(), + }), + motionLanguage: z.object({ + internalMotionIntensity: z.enum(['low', 'medium', 'high']).default('low'), + hasMaskReveals: z.boolean().default(false), + hasViewportSlides: z.boolean().default(false), + preferredMotionPreset: z.enum([ + 'static', + 'ken_burns_in', + 'push_in', + 'push_out', + 'pan_left', + 'pan_right', + 'pan_up', + 'pan_down', + 'snap_zoom', + 'parallax_drift', + 'tilt_in', + 'beat_pulse', + 'reveal_pan', + ]).default('ken_burns_in'), + preferredTransitionPreset: z.enum(['cut', 'crossfade', 'whip_cut', 'snap_cut']).default('cut'), + notes: z.array(z.string()).default([]), + }), + audioOnsets: z.array(TemplateAudioOnset).default([]), + events: z.array(TemplateMotionEvent).default([]), + strategySummary: z.string().default(''), + renderHints: z.array(z.string()).default([]), +}); +export type TemplateProfile = z.infer; + +export function isActionableTemplateProfile(profile: TemplateProfile | undefined): profile is TemplateProfile { + if (!profile) return false; + return ( + profile.layoutPreset === 'cinematic_matte' || + profile.layoutPreset === 'camera_carousel' || + profile.layoutPreset === 'film_viewfinder_carousel' || + profile.layoutPreset === 'letterbox_frame' || + profile.motionLanguage.hasMaskReveals || + profile.motionLanguage.hasViewportSlides || + profile.events.length > 0 + ); +} + +export function isCarouselTemplateProfile(profile: TemplateProfile | undefined): boolean { + return Boolean( + profile && + (profile.layoutPreset === 'camera_carousel' || + profile.layoutPreset === 'film_viewfinder_carousel' || + profile.renderHints.includes('carousel_strip')), + ); +} diff --git a/apps/api/src/core/timeline.ts b/apps/api/src/core/timeline.ts index 911f676..c43e8dc 100644 --- a/apps/api/src/core/timeline.ts +++ b/apps/api/src/core/timeline.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; import { TrackKind } from './enums'; +import { RhythmAlignmentPlan, RhythmAnchor } from './rhythm'; +import { TemplateProfile } from './template'; /** 时间线 item 的素材来源——必须可追溯到真实素材 / 补全产物 / 原始文件。 */ export const TimelineSource = z.discriminatedUnion('kind', [ @@ -9,23 +11,112 @@ export const TimelineSource = z.discriminatedUnion('kind', [ ]); export type TimelineSource = z.infer; +export const MotionPreset = z.enum([ + 'static', + 'ken_burns_in', + 'push_in', + 'push_out', + 'pan_left', + 'pan_right', + 'pan_up', + 'pan_down', + 'snap_zoom', + 'parallax_drift', + 'tilt_in', + 'beat_pulse', + 'reveal_pan', +]); +export type MotionPreset = z.infer; + +export const TransitionPreset = z.enum(['cut', 'crossfade', 'whip_cut', 'snap_cut']); +export type TransitionPreset = z.infer; + +export const CropPreset = z.enum(['center', 'top', 'bottom', 'left', 'right', 'closeup', 'contain']); +export type CropPreset = z.infer; + +export const FramePolicy = z.enum([ + 'portrait_cover', + 'portrait_safe_crop', + 'landscape_viewport', + 'blurred_pad', + 'split_montage', + 'reference_viewport', + 'real_background_lower_third', +]); +export type FramePolicy = z.infer; + +export const CardStylePreset = z.enum([ + 'minimal_dark', + 'title_bar', + 'sticker_pop', + 'cover_card', + 'editorial_caption', + 'social_punch', + 'clean_product', + 'lifestyle_story', +]); +export type CardStylePreset = z.infer; + +export const CardAnimationPreset = z.enum([ + 'fade_push', + 'slide_left', + 'snap_pop', + 'wipe_up', + 'soft_crossfade', +]); +export type CardAnimationPreset = z.infer; + +export const BeatGrid = z.object({ + bpm: z.number().positive(), + offsetSec: z.number().min(0).default(0), + beatsSec: z.array(z.number().min(0)).default([]), + source: z.enum(['estimated', 'sample_hint', 'detected']).default('estimated'), + confidence: z.enum(['low', 'medium', 'high']).default('low'), + rationale: z.string().default(''), +}); +export type BeatGrid = z.infer; + export const TimelineItem = z .object({ id: z.string(), track: TrackKind, startSec: z.number().min(0), endSec: z.number().min(0), + /** 对视频素材取用的入点 / 出点;render 可据此裁切或循环素材。 */ + sourceInSec: z.number().min(0).optional(), + sourceOutSec: z.number().min(0).optional(), + /** 可执行剪辑意图:render 层把这些 preset 转成 FFmpeg filter。 */ + motionPreset: MotionPreset.optional(), + transitionPreset: TransitionPreset.optional(), + cropPreset: CropPreset.optional(), + /** 画幅 / 容器策略:用于统一横屏、竖屏和参考素材的呈现方式。 */ + framePolicy: FramePolicy.optional(), + /** 由样例库 packaging pattern 推导的卡片包装样式 / 动画。 */ + cardStylePreset: CardStylePreset.optional(), + cardAnimationPreset: CardAnimationPreset.optional(), + /** 真实素材背景上的包装文字;用于把样例的标题 / 文案样式迁移到用户素材,而不是生成全屏文字卡。 */ + overlayText: z.string().optional(), /** 指向蓝图槽位,便于追溯"这段为哪个结构槽位服务"。 */ slotRef: z.string().optional(), + /** 指向前置 DirectorPlan 的 shot,便于校验时间线是否执行导演意图。 */ + shotRef: z.string().optional(), + /** 节奏锚点:说明该 item 的边界如何吸附到样例节奏 / 目标 BGM / 全局相似样例。 */ + rhythmAnchor: RhythmAnchor.optional(), source: TimelineSource, }) - .refine((i) => i.endSec > i.startSec, { message: 'endSec 必须大于 startSec' }); + .refine((i) => i.endSec > i.startSec, { message: 'endSec 必须大于 startSec' }) + .refine((i) => i.sourceOutSec == null || i.sourceInSec == null || i.sourceOutSec > i.sourceInSec, { + message: 'sourceOutSec 必须大于 sourceInSec', + }); export type TimelineItem = z.infer; export const Timeline = z.object({ id: z.string(), projectId: z.string(), durationSec: z.number().positive(), + beatGrid: BeatGrid.optional(), + rhythmPlan: RhythmAlignmentPlan.optional(), + templateProfile: TemplateProfile.optional(), items: z.array(TimelineItem).min(1), }); export type Timeline = z.infer; diff --git a/apps/api/src/core/versions.ts b/apps/api/src/core/versions.ts new file mode 100644 index 0000000..ea66cc2 --- /dev/null +++ b/apps/api/src/core/versions.ts @@ -0,0 +1,84 @@ +import { applyBlueprintPatch } from './applyPatch'; +import type { VideoStructureBlueprint } from './blueprint'; +import { type MigrationPlan, type RuleBasedMigrationInput, runRuleBasedMigration } from './migration'; +import type { BlueprintPatch } from './patch'; + +export interface VersionPreset { + id: string; + label: string; + describe: string; + patch: BlueprintPatch; +} + +/** 多版本预设:对蓝图做结构化变换(非换 prompt),差异明确、可解释。 */ +export const VERSION_PRESETS: VersionPreset[] = [ + { + id: 'fast', + label: '高节奏版', + describe: '镜头更密、平均镜头更短、开场更快', + patch: { + origin: 'preset', + note: '高节奏', + ops: [ + { path: 'rhythmStructure.cutDensity', op: 'set', value: 'high' }, + { path: 'rhythmStructure.avgShotSec', op: 'scale', value: 0.7 }, + { path: 'segment.hook.durationRatio', op: 'scale', value: 0.7 }, + ], + }, + }, + { + id: 'cinematic', + label: '高质感版', + describe: '慢节奏、镜头更长、字幕克制', + patch: { + origin: 'preset', + note: '高质感', + ops: [ + { path: 'rhythmStructure.cutDensity', op: 'set', value: 'low' }, + { path: 'rhythmStructure.avgShotSec', op: 'scale', value: 1.4 }, + { path: 'packagingStructure.subtitleDensity', op: 'set', value: 'sparse' }, + ], + }, + }, + { + id: 'strong_hook', + label: '强钩子版', + describe: '开场更长更抓人、高潮前移', + patch: { + origin: 'preset', + note: '强钩子', + ops: [ + { path: 'segment.hook.durationRatio', op: 'scale', value: 1.6 }, + { path: 'rhythmStructure.peakAt', op: 'set', value: 0.45 }, + ], + }, + }, +]; + +export interface VersionVariant { + id: string; + label: string; + describe: string; + patch: BlueprintPatch; + blueprint: VideoStructureBlueprint; + migration: MigrationPlan; +} + +export type RuleMigrateFn = (input: RuleBasedMigrationInput) => MigrationPlan; + +/** + * 多版本生成:对同一蓝图套用各预设变换 → 变体蓝图 → 迁移方案。 + * 默认用规则版迁移(同步、无需 LLM/key);可注入 migrate 走专家版。 + */ +export function generateVersions( + input: RuleBasedMigrationInput, + opts: { presets?: VersionPreset[]; migrate?: RuleMigrateFn } = {}, +): VersionVariant[] { + const presets = opts.presets ?? VERSION_PRESETS; + const migrate = opts.migrate ?? runRuleBasedMigration; + return presets.map((preset) => { + const blueprint = applyBlueprintPatch(input.blueprint, preset.patch); + const migration = migrate({ ...input, blueprint }); + return { id: preset.id, label: preset.label, describe: preset.describe, patch: preset.patch, blueprint, migration }; + }); +} diff --git a/apps/api/src/llm/__tests__/ark.test.ts b/apps/api/src/llm/__tests__/ark.test.ts new file mode 100644 index 0000000..cd06272 --- /dev/null +++ b/apps/api/src/llm/__tests__/ark.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { chatJson, collectDebugTrace } from '../ark'; + +describe('chatJson debug trace', () => { + it('records retry attempts and redacts image payloads', async () => { + const schema = z.object({ ok: z.boolean() }); + let calls = 0; + const traced = await collectDebugTrace(() => + chatJson( + schema, + [ + { role: 'system', content: 'return json' }, + { + role: 'user', + content: [ + { type: 'text', text: 'inspect frame' }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,secret-image-bytes' } }, + ], + }, + ], + { + traceName: 'trace_test', + chatFn: async () => { + calls += 1; + return calls === 1 ? '{ not json' : JSON.stringify({ ok: true }); + }, + }, + ), + ); + + expect(traced.ok).toBe(true); + if (!traced.ok) return; + expect(traced.result).toEqual({ ok: true }); + expect(traced.trace).toHaveLength(2); + expect(traced.trace[0]).toMatchObject({ + name: 'trace_test', + attempt: 0, + status: 'invalid_json', + raw: '{ not json', + validationError: '输出不是合法 JSON', + }); + expect(traced.trace[1]).toMatchObject({ + name: 'trace_test', + attempt: 1, + status: 'success', + parsed: { ok: true }, + }); + const user = traced.trace[0].messages[1].content; + expect(Array.isArray(user) ? user[1] : null).toEqual({ + type: 'image_url', + image_url: { url: '[image_url:redacted]' }, + }); + }); +}); diff --git a/apps/api/src/llm/ark.ts b/apps/api/src/llm/ark.ts index a07c01b..a9f6981 100644 --- a/apps/api/src/llm/ark.ts +++ b/apps/api/src/llm/ark.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import type { z } from 'zod'; import { requireArkConfig } from '../config'; @@ -18,6 +19,37 @@ export interface ChatOptions { export type ChatFn = (messages: ChatMessage[], opts?: ChatOptions) => Promise; +export interface DebugTraceEntry { + id: string; + name: string; + attempt: number; + status: 'success' | 'invalid_json' | 'invalid_schema' | 'error'; + durationMs: number; + messages: ChatMessage[]; + raw?: string; + parsed?: unknown; + validationError?: string; + error?: string; +} + +export type DebugTraceResult = + | { ok: true; result: T; trace: DebugTraceEntry[] } + | { ok: false; error: unknown; trace: DebugTraceEntry[] }; + +const traceStorage = new AsyncLocalStorage(); +const MAX_TEXT_CHARS = 4000; +const MAX_RAW_CHARS = 8000; + +export async function collectDebugTrace(fn: () => Promise): Promise> { + const trace: DebugTraceEntry[] = []; + try { + const result = await traceStorage.run(trace, fn); + return { ok: true, result, trace }; + } catch (error) { + return { ok: false, error, trace }; + } +} + /** 调用火山方舟(OpenAI 兼容 /chat/completions),返回文本内容。 */ export const chat: ChatFn = async (messages, opts = {}) => { const cfg = requireArkConfig(); @@ -47,7 +79,7 @@ function stripFences(s: string): string { export async function chatJson( schema: S, messages: ChatMessage[], - opts: ChatOptions & { chatFn?: ChatFn; retries?: number } = {}, + opts: ChatOptions & { chatFn?: ChatFn; retries?: number; traceName?: string } = {}, ): Promise> { const chatFn = opts.chatFn ?? chat; const retries = opts.retries ?? 1; @@ -55,19 +87,112 @@ export async function chatJson( let msgs = messages; for (let attempt = 0; attempt <= retries; attempt++) { - const raw = await chatFn(msgs, opts); + const started = Date.now(); + let raw = ''; + try { + raw = await chatFn(msgs, opts); + } catch (e) { + pushTrace({ + id: traceId(opts.traceName, attempt), + name: opts.traceName ?? schemaName(schema), + attempt, + status: 'error', + durationMs: Date.now() - started, + messages: redactMessages(msgs), + error: errMsg(e), + }); + throw e; + } let parsed: unknown; try { parsed = JSON.parse(stripFences(raw)); } catch { lastErr = '输出不是合法 JSON'; + pushTrace({ + id: traceId(opts.traceName, attempt), + name: opts.traceName ?? schemaName(schema), + attempt, + status: 'invalid_json', + durationMs: Date.now() - started, + messages: redactMessages(msgs), + raw: truncate(raw, MAX_RAW_CHARS), + validationError: lastErr, + }); msgs = [...messages, { role: 'user', content: '上次输出无法解析为 JSON,请只输出合法 JSON,不要任何解释或代码块标记。' }]; continue; } const result = schema.safeParse(parsed); - if (result.success) return result.data; + if (result.success) { + pushTrace({ + id: traceId(opts.traceName, attempt), + name: opts.traceName ?? schemaName(schema), + attempt, + status: 'success', + durationMs: Date.now() - started, + messages: redactMessages(msgs), + raw: truncate(raw, MAX_RAW_CHARS), + parsed: redactParsed(result.data), + }); + return result.data; + } lastErr = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '); + pushTrace({ + id: traceId(opts.traceName, attempt), + name: opts.traceName ?? schemaName(schema), + attempt, + status: 'invalid_schema', + durationMs: Date.now() - started, + messages: redactMessages(msgs), + raw: truncate(raw, MAX_RAW_CHARS), + parsed: redactParsed(parsed), + validationError: lastErr, + }); msgs = [...messages, { role: 'user', content: `上次输出不符合要求:${lastErr}。请修正后只输出合法 JSON。` }]; } throw new Error(`chatJson 校验失败:${lastErr}`); } + +function pushTrace(entry: DebugTraceEntry) { + traceStorage.getStore()?.push(entry); +} + +function traceId(name: string | undefined, attempt: number): string { + const base = (name ?? 'chat_json').replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 48); + return `${base}_${attempt + 1}_${Date.now().toString(36)}`; +} + +function schemaName(schema: z.ZodTypeAny): string { + return schema.description || schema.constructor.name || 'chat_json'; +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function redactMessages(messages: ChatMessage[]): ChatMessage[] { + return messages.map((message) => ({ + role: message.role, + content: redactContent(message.content), + })); +} + +function redactContent(content: ChatContent): ChatContent { + if (typeof content === 'string') return truncate(content, MAX_TEXT_CHARS); + return content.map((part) => { + if (part.type === 'text') return { type: 'text', text: truncate(part.text, MAX_TEXT_CHARS) }; + return { type: 'image_url', image_url: { url: '[image_url:redacted]' } }; + }); +} + +function redactParsed(value: unknown): unknown { + return JSON.parse(JSON.stringify(value, (_key, raw) => { + if (typeof raw === 'string') { + if (raw.startsWith('data:image/') || raw.length > MAX_TEXT_CHARS) return truncate(raw, MAX_TEXT_CHARS); + } + return raw; + })); +} + +function truncate(value: string, max: number): string { + return value.length > max ? `${value.slice(0, max)}…[truncated ${value.length - max} chars]` : value; +} diff --git a/apps/api/src/media/__tests__/asr.test.ts b/apps/api/src/media/__tests__/asr.test.ts new file mode 100644 index 0000000..b8f0e27 --- /dev/null +++ b/apps/api/src/media/__tests__/asr.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeAsrResponse } from '../asr'; + +describe('ASR response normalization', () => { + it('parses verbose segment timestamps', () => { + const cues = normalizeAsrResponse({ + segments: [ + { start: '0.10', end: 1.25, text: ' 开场抓停 ', no_speech_prob: 0.1 }, + { start: 1.25, end: 2.5, text: '展示卖点', avg_logprob: -0.2 }, + ], + }); + expect(cues).toEqual([ + { startSec: 0.1, endSec: 1.25, text: '开场抓停', confidence: 0.9 }, + { startSec: 1.25, endSec: 2.5, text: '展示卖点', confidence: expect.any(Number) }, + ]); + }); + + it('merges word timestamps into readable cues', () => { + const cues = normalizeAsrResponse({ + words: [ + { start: 0, end: 0.4, word: 'quick' }, + { start: 0.4, end: 0.8, word: 'demo' }, + { start: 1.8, end: 2.1, word: '收尾' }, + ], + }); + expect(cues).toHaveLength(2); + expect(cues[0].text).toBe('quick demo'); + expect(cues[1].text).toBe('收尾'); + }); + + it('falls back to full-span plain text when timestamps are absent', () => { + const cues = normalizeAsrResponse({ text: '完整旁白' }, 8); + expect(cues).toEqual([{ startSec: 0, endSec: 8, text: '完整旁白' }]); + }); +}); diff --git a/apps/api/src/media/__tests__/audioQuality.test.ts b/apps/api/src/media/__tests__/audioQuality.test.ts new file mode 100644 index 0000000..de42386 --- /dev/null +++ b/apps/api/src/media/__tests__/audioQuality.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { parseVolumeDetectOutput } from '../audioQuality'; + +describe('audio quality probe', () => { + it('marks near-silent AAC shells as silent risk', () => { + const stats = parseVolumeDetectOutput(` + [Parsed_volumedetect_0] n_samples: 2856960 + [Parsed_volumedetect_0] mean_volume: -91.0 dB + [Parsed_volumedetect_0] max_volume: -91.0 dB + `); + + expect(stats).toMatchObject({ + hasSamples: true, + meanVolumeDb: -91, + maxVolumeDb: -91, + silentAudioRisk: true, + }); + }); + + it('keeps normal music or ambience usable', () => { + const stats = parseVolumeDetectOutput(` + [Parsed_volumedetect_0] n_samples: 1054720 + [Parsed_volumedetect_0] mean_volume: -29.3 dB + [Parsed_volumedetect_0] max_volume: -14.0 dB + `); + + expect(stats.silentAudioRisk).toBe(false); + }); +}); diff --git a/apps/api/src/media/__tests__/beatDetect.test.ts b/apps/api/src/media/__tests__/beatDetect.test.ts new file mode 100644 index 0000000..cf04a49 --- /dev/null +++ b/apps/api/src/media/__tests__/beatDetect.test.ts @@ -0,0 +1,65 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { detectMusicStructure } from '../beatDetect'; + +const ffmpegAvailable = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0; + +describe('music structure detector', () => { + it.runIf(ffmpegAvailable)('detects downbeats, phrase boundaries and a drop from pulsed audio', async () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-beat-detect-')); + const wavPath = join(dir, 'pulsed.wav'); + writeFileSync(wavPath, makePulsedWav()); + try { + const structure = await detectMusicStructure({ sourcePath: wavPath, durationSec: 12, cutDensity: 'medium' }); + expect(structure).not.toBeNull(); + expect(structure!.beatGrid.bpm).toBeGreaterThan(112); + expect(structure!.beatGrid.bpm).toBeLessThan(128); + expect(structure!.downbeatsSec.length).toBeGreaterThan(1); + expect(structure!.phraseBoundariesSec.length).toBeGreaterThan(0); + expect(structure!.sections.some((section) => section.kind === 'drop')).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +function makePulsedWav(): Buffer { + const sampleRate = 22_050; + const durationSec = 12; + const samples = sampleRate * durationSec; + const pcm = Buffer.alloc(samples * 2); + for (let i = 0; i < samples; i += 1) { + const t = i / sampleRate; + const beat = Math.round(t / 0.5); + const beatAt = beat * 0.5; + const dist = Math.abs(t - beatAt); + const isDownbeat = beat % 4 === 0; + const baseAmp = t >= 4 ? 0.95 : 0.36; + const envelope = dist < 0.055 ? Math.exp(-dist * 42) : 0; + const tone = Math.sin(2 * Math.PI * (isDownbeat ? 95 : 140) * t); + const value = Math.max(-1, Math.min(1, tone * envelope * baseAmp * (isDownbeat ? 1 : 0.75))); + pcm.writeInt16LE(Math.round(value * 32767), i * 2); + } + return wavBuffer(pcm, sampleRate); +} + +function wavBuffer(pcm: Buffer, sampleRate: number): Buffer { + const header = Buffer.alloc(44); + header.write('RIFF', 0); + header.writeUInt32LE(36 + pcm.length, 4); + header.write('WAVE', 8); + header.write('fmt ', 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(sampleRate * 2, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write('data', 36); + header.writeUInt32LE(pcm.length, 40); + return Buffer.concat([header, pcm]); +} diff --git a/apps/api/src/media/analyze.ts b/apps/api/src/media/analyze.ts index 0a7f556..2d8fc80 100644 --- a/apps/api/src/media/analyze.ts +++ b/apps/api/src/media/analyze.ts @@ -1,9 +1,11 @@ import { join } from 'node:path'; import type { Evidence } from '../core/explain'; import type { SampleAnalysis } from '../core/sample'; +import { transcribeAudio } from './asr'; import { MAX_KEYFRAMES, extractCover, extractKeyframes } from './frames'; import { probeVideo } from './probe'; import { detectScenes } from './scenes'; +import { detectTemplateProfile } from './templateDetect'; import { dedupeSorted, round, spreadPick } from './util'; /** 合并间隔小于此值的密集切点(抑制闪切/快剪误检)。 */ @@ -42,11 +44,24 @@ export async function analyzeSample(opts: AnalyzeOptions): Promise ({ index: i + 1, atSec: round(t) })); const shotCount = scenes.length + 1; // 切点数 + 1 = 镜头数 - report(55, 'keyframes', '抽取关键帧…'); + report(43, 'template', '分析画幅模板、内部运动和音频 onset…'); + const templateProfile = await detectTemplateProfile({ + sampleId: opts.sampleId, + sourcePath: opts.sourcePath, + metadata, + hardCuts: cuts, + }).catch(() => undefined); + + report(55, 'asr', '尝试 ASR 带时间戳转写…'); + const transcript = metadata.hasAudio + ? await transcribeAudio({ sourcePath: opts.sourcePath, durationSec: metadata.durationSec }) + : { cues: [], source: 'no_audio' as const }; + + report(65, 'keyframes', '抽取关键帧…'); const keyframeTimes = spreadPick(dedupeSorted([0, ...cuts], MIN_SCENE_GAP), MAX_KEYFRAMES); const keyframes = await extractKeyframes(opts.sourcePath, keyframeTimes, join(opts.outDir, 'keyframes')); - report(85, 'cover', '生成封面…'); + report(87, 'cover', '生成封面…'); const coverPath = await extractCover( opts.sourcePath, Math.min(1, metadata.durationSec / 2), @@ -61,8 +76,28 @@ export async function analyzeSample(opts: AnalyzeOptions): Promise s.atSec.toFixed(2)).join(', '), }, + ...(templateProfile + ? [ + { + type: 'template_profile', + detail: `${templateProfile.layoutPreset};内部运动 ${templateProfile.motionLanguage.internalMotionIntensity};模板事件 ${templateProfile.events.length} 个;音频 onset ${templateProfile.audioOnsets.length} 个`, + ref: templateProfile.renderHints.join(', '), + }, + ] + : []), { type: 'keyframe', detail: `抽取 ${keyframes.length} 帧关键帧(≤12)` }, { type: 'audio', detail: metadata.hasAudio ? `含音轨 ${metadata.audioCodec}` : '无音轨' }, + { + type: 'asr', + detail: + transcript.source === 'asr_endpoint' + ? `ASR 产出 ${transcript.cues.length} 条带时间戳转写` + : transcript.source === 'not_configured' + ? '未配置 ASR endpoint,跳过转写' + : transcript.source === 'no_audio' + ? '无音轨,跳过 ASR' + : `ASR 失败,已跳过:${transcript.error ?? 'unknown error'}`, + }, ]; report(100, 'done', '解析完成'); @@ -72,6 +107,8 @@ export async function analyzeSample(opts: AnalyzeOptions): Promise; + +export async function transcribeAudio(input: TranscribeAudioInput): Promise { + const cfg = getAsrConfig(); + if (!cfg) return { cues: [], source: 'not_configured' }; + + try { + const bytes = await readFile(input.sourcePath); + const form = new FormData(); + form.set('model', cfg.model); + form.set('response_format', 'verbose_json'); + form.append('timestamp_granularities[]', 'segment'); + form.set('file', new Blob([new Uint8Array(bytes)], { type: mimeFor(input.sourcePath) }), basename(input.sourcePath)); + + const response = await fetch(`${cfg.baseUrl}${cfg.path}`, { + method: 'POST', + headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : undefined, + body: form, + }); + const text = await response.text(); + if (!response.ok) { + return { cues: [], source: 'failed', error: `ASR ${response.status}: ${text.slice(0, 300)}` }; + } + const json = safeJson(text); + const cues = normalizeAsrResponse(json, input.durationSec); + return { cues, source: 'asr_endpoint' }; + } catch (e) { + return { cues: [], source: 'failed', error: e instanceof Error ? e.message : String(e) }; + } +} + +export function normalizeAsrResponse(value: unknown, durationSec?: number): TranscriptCue[] { + if (!value || typeof value !== 'object') return []; + const body = value as UnknownRecord; + const segmentCues = normalizeSegments(body.segments); + if (segmentCues.length) return segmentCues; + const wordCues = normalizeWords(body.words); + if (wordCues.length) return mergeWordsToCues(wordCues); + const text = typeof body.text === 'string' ? body.text.trim() : ''; + if (!text || !durationSec || durationSec <= 0) return []; + return TranscriptCue.array().parse([{ startSec: 0, endSec: durationSec, text }]); +} + +function normalizeSegments(value: unknown): TranscriptCue[] { + if (!Array.isArray(value)) return []; + const cues = value + .map((segment) => { + if (!segment || typeof segment !== 'object') return null; + const item = segment as UnknownRecord; + const startSec = numberField(item.start); + const endSec = numberField(item.end); + const text = typeof item.text === 'string' ? item.text.trim() : ''; + if (startSec == null || endSec == null || !text) return null; + return cueWithOptionalConfidence(round(startSec), round(endSec), text, confidenceFromSegment(item)); + }) + .filter((cue): cue is TranscriptCue => Boolean(cue)); + return TranscriptCue.array().parse(cues); +} + +function normalizeWords(value: unknown): TranscriptCue[] { + if (!Array.isArray(value)) return []; + const cues = value + .map((word) => { + if (!word || typeof word !== 'object') return null; + const item = word as UnknownRecord; + const startSec = numberField(item.start); + const endSec = numberField(item.end); + const text = typeof item.word === 'string' + ? item.word.trim() + : typeof item.text === 'string' + ? item.text.trim() + : ''; + if (startSec == null || endSec == null || !text) return null; + return cueWithOptionalConfidence(round(startSec), round(endSec), text, numberField(item.confidence)); + }) + .filter((cue): cue is TranscriptCue => Boolean(cue)); + return TranscriptCue.array().parse(cues); +} + +function mergeWordsToCues(words: TranscriptCue[]): TranscriptCue[] { + const cues: TranscriptCue[] = []; + let current: TranscriptCue | null = null; + for (const word of words) { + if (!current) { + current = { ...word }; + continue; + } + const shouldStart = + word.startSec - current.endSec > 0.8 || + `${current.text}${word.text}`.length > 48 || + current.endSec - current.startSec > 5.5; + if (shouldStart) { + cues.push(current); + current = { ...word }; + } else { + current = { + startSec: current.startSec, + endSec: word.endSec, + text: `${current.text}${needsSpace(current.text, word.text) ? ' ' : ''}${word.text}`.trim(), + ...optionalConfidence(averageConfidence(current.confidence, word.confidence)), + }; + } + } + if (current) cues.push(current); + return TranscriptCue.array().parse(cues); +} + +function confidenceFromSegment(item: UnknownRecord): number | undefined { + const explicit = numberField(item.confidence); + if (explicit != null) return clamp01(explicit); + const noSpeechProb = numberField(item.no_speech_prob); + if (noSpeechProb != null) return clamp01(1 - noSpeechProb); + const avgLogprob = numberField(item.avg_logprob); + if (avgLogprob != null) return clamp01(Math.exp(avgLogprob)); + return undefined; +} + +function cueWithOptionalConfidence( + startSec: number, + endSec: number, + text: string, + confidence: number | undefined, +): TranscriptCue { + return { + startSec, + endSec, + text, + ...optionalConfidence(confidence), + }; +} + +function optionalConfidence(confidence: number | undefined): Pick | Record { + return confidence == null ? {} : { confidence: clamp01(confidence) }; +} + +function numberField(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value); + return undefined; +} + +function averageConfidence(a: number | undefined, b: number | undefined): number | undefined { + if (a == null) return b; + if (b == null) return a; + return round((a + b) / 2); +} + +function needsSpace(left: string, right: string): boolean { + return /[a-zA-Z0-9]$/.test(left) && /^[a-zA-Z0-9]/.test(right); +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return { text }; + } +} + +function mimeFor(path: string): string { + const ext = extname(path).toLowerCase(); + if (ext === '.mp3') return 'audio/mpeg'; + if (ext === '.wav') return 'audio/wav'; + if (ext === '.m4a') return 'audio/mp4'; + if (ext === '.aac') return 'audio/aac'; + if (ext === '.flac') return 'audio/flac'; + if (ext === '.ogg') return 'audio/ogg'; + if (ext === '.mp4') return 'video/mp4'; + if (ext === '.mov') return 'video/quicktime'; + return 'application/octet-stream'; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function round(n: number): number { + return Math.round(n * 1000) / 1000; +} diff --git a/apps/api/src/media/audioQuality.ts b/apps/api/src/media/audioQuality.ts new file mode 100644 index 0000000..6fca693 --- /dev/null +++ b/apps/api/src/media/audioQuality.ts @@ -0,0 +1,68 @@ +import { spawn } from 'node:child_process'; + +export interface AudioVolumeStats { + hasSamples: boolean; + meanVolumeDb?: number; + maxVolumeDb?: number; + silentAudioRisk: boolean; +} + +export const SILENT_AUDIO_MAX_VOLUME_DB = -60; +export const SILENT_AUDIO_MEAN_VOLUME_DB = -85; + +export async function probeAudioVolume(sourcePath: string): Promise { + const output = await runVolumeDetect(sourcePath); + return parseVolumeDetectOutput(output); +} + +export function parseVolumeDetectOutput(output: string): AudioVolumeStats { + const meanVolumeDb = numberAfter(output, /mean_volume:\s*(-?\d+(?:\.\d+)?)\s*dB/); + const maxVolumeDb = numberAfter(output, /max_volume:\s*(-?\d+(?:\.\d+)?)\s*dB/); + const samples = numberAfter(output, /n_samples:\s*(\d+)/); + const hasSamples = samples == null ? maxVolumeDb != null || meanVolumeDb != null : samples > 0; + return { + hasSamples, + meanVolumeDb, + maxVolumeDb, + silentAudioRisk: isSilentAudioStats({ hasSamples, meanVolumeDb, maxVolumeDb }), + }; +} + +export function isSilentAudioStats(stats: Pick): boolean { + if (!stats.hasSamples) return true; + if (stats.maxVolumeDb == null && stats.meanVolumeDb == null) return true; + if (stats.maxVolumeDb != null && stats.maxVolumeDb <= SILENT_AUDIO_MAX_VOLUME_DB) return true; + if (stats.meanVolumeDb != null && stats.meanVolumeDb <= SILENT_AUDIO_MEAN_VOLUME_DB) return true; + return false; +} + +function runVolumeDetect(sourcePath: string): Promise { + return new Promise((resolve, reject) => { + const p = spawn('ffmpeg', [ + '-hide_banner', + '-i', + sourcePath, + '-vn', + '-af', + 'volumedetect', + '-f', + 'null', + '-', + ]); + let err = ''; + p.stderr.on('data', (d) => { + err += d.toString(); + }); + p.on('error', reject); + p.on('close', (code) => + code === 0 ? resolve(err) : reject(new Error(`audio volume probe exited ${code}:\n${err}`)), + ); + }); +} + +function numberAfter(output: string, pattern: RegExp): number | undefined { + const match = output.match(pattern); + if (!match) return undefined; + const value = Number(match[1]); + return Number.isFinite(value) ? value : undefined; +} diff --git a/apps/api/src/media/beatDetect.ts b/apps/api/src/media/beatDetect.ts new file mode 100644 index 0000000..b983d6d --- /dev/null +++ b/apps/api/src/media/beatDetect.ts @@ -0,0 +1,433 @@ +import { spawn } from 'node:child_process'; +import { Buffer } from 'node:buffer'; +import { MusicSection } from '../core/rhythm'; +import { BeatGrid } from '../core/timeline'; + +export interface DetectBeatGridInput { + sourcePath: string; + durationSec: number; + cutDensity: string; +} + +export interface DetectedAudioOnset { + timeSec: number; + relativeTime: number; + strength: 'weak' | 'medium' | 'strong'; + energyDb: number; +} + +export interface DetectedMusicStructure { + beatGrid: BeatGrid; + sections: MusicSection[]; + downbeatsSec: number[]; + phraseBoundariesSec: number[]; + dropTimeSec?: number; + confidence: 'low' | 'medium' | 'high'; + rationale: string; +} + +type PcmAnalysis = { + durationSec: number; + hopSec: number; + times: number[]; + rms: number[]; + onset: number[]; +}; + +export async function detectBeatGrid(input: DetectBeatGridInput): Promise { + const structure = await detectMusicStructure(input); + if (structure) return structure.beatGrid; + return detectBeatGridFromEnergy(input); +} + +export async function detectMusicStructure(input: DetectBeatGridInput): Promise { + const pcm = await analyzePcmOnsets(input.sourcePath, input.durationSec); + if (!pcm || pcm.onset.length < 16) return null; + const peaks = pickOnsetPeaks(pcm); + if (peaks.length < 4) return null; + const tempo = estimateTempo(pcm, input.cutDensity); + const step = 60 / tempo.bpm; + const offsetSec = estimateBeatOffset(peaks, step); + const beatsSec = buildBeatTimes(offsetSec, step, input.durationSec); + if (beatsSec.length < 4) return null; + const downbeatPhase = estimateDownbeatPhase(beatsSec, pcm); + const downbeatsSec = beatsSec.filter((_, index) => (index - downbeatPhase + 400) % 4 === 0); + const phraseBoundariesSec = beatsSec.filter((_, index) => (index - downbeatPhase + 800) % 8 === 0); + const sections = inferSectionsFromPhrases({ + durationSec: input.durationSec, + phraseBoundariesSec, + downbeatsSec, + pcm, + }); + const drop = sections.find((section) => section.kind === 'drop'); + const confidence = tempo.score > 0.58 && peaks.length >= 10 + ? 'high' + : tempo.score > 0.36 && peaks.length >= 6 + ? 'medium' + : 'low'; + const beatGrid = BeatGrid.parse({ + bpm: tempo.bpm, + offsetSec: round(offsetSec), + beatsSec, + source: 'detected', + confidence, + rationale: `PCM onset detector: ${peaks.length} onsets, tempo=${tempo.bpm} BPM, downbeatPhase=${downbeatPhase}, phraseBoundaries=${phraseBoundariesSec.length}, drop=${drop ? `${drop.startSec}s` : 'n/a'}.`, + }); + return { + beatGrid, + sections, + downbeatsSec, + phraseBoundariesSec, + dropTimeSec: drop?.startSec, + confidence, + rationale: beatGrid.rationale, + }; +} + +async function detectBeatGridFromEnergy(input: DetectBeatGridInput): Promise { + const samples = await sampleAudioEnergy(input.sourcePath); + if (samples.length < 4) return null; + const peaks = pickEnergyPeaks(samples, input.durationSec); + if (peaks.length < 3) return null; + const intervals = peaks.slice(1).map((peak, i) => peak - peaks[i]).filter((v) => v >= 0.28 && v <= 1.2); + const median = intervals.length ? intervals.sort((a, b) => a - b)[Math.floor(intervals.length / 2)] : fallbackStep(input.cutDensity); + const step = median || fallbackStep(input.cutDensity); + const bpm = Math.round(60 / step); + const offsetSec = peaks[0] ?? 0; + const beatsSec: number[] = []; + for (let t = offsetSec; t <= input.durationSec + step; t += step) beatsSec.push(round(Math.min(input.durationSec, t))); + for (let t = offsetSec - step; t >= 0; t -= step) beatsSec.unshift(round(t)); + return BeatGrid.parse({ + bpm, + offsetSec: round(offsetSec), + beatsSec: Array.from(new Set(beatsSec)).filter((beat) => beat >= 0 && beat <= input.durationSec), + source: 'detected', + confidence: peaks.length >= 6 ? 'medium' : 'low', + rationale: `FFmpeg astats energy peaks: ${peaks.length} peaks, median interval ${round(step)}s.`, + }); +} + +async function analyzePcmOnsets(file: string, durationSec: number): Promise { + const sampleRate = 22050; + const pcm = await readPcmF32(file, sampleRate, Math.min(Math.max(durationSec + 0.5, 1), 180)); + if (!pcm.length) return null; + const frameSize = 1024; + const hopSize = 512; + const times: number[] = []; + const rms: number[] = []; + const onset: number[] = []; + let prevRms = 0; + for (let start = 0; start + frameSize <= pcm.length; start += hopSize) { + let energy = 0; + for (let i = start; i < start + frameSize; i += 1) energy += pcm[i] * pcm[i]; + const value = Math.sqrt(energy / frameSize); + times.push(round(start / sampleRate)); + rms.push(value); + onset.push(Math.max(0, value - prevRms)); + prevRms = value; + } + const smoothRms = smooth(rms, 5); + const smoothOnset = normalize(smooth(onset, 3)); + return { + durationSec, + hopSec: hopSize / sampleRate, + times, + rms: normalize(smoothRms), + onset: smoothOnset, + }; +} + +function readPcmF32(file: string, sampleRate: number, maxDurationSec: number): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + const p = spawn('ffmpeg', [ + '-hide_banner', + '-nostats', + '-i', + file, + '-t', + String(round(maxDurationSec)), + '-vn', + '-ac', + '1', + '-ar', + String(sampleRate), + '-f', + 'f32le', + 'pipe:1', + ]); + p.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)); + p.on('error', () => resolve(new Float32Array())); + p.on('close', (code) => { + if (code !== 0 || !chunks.length) { + resolve(new Float32Array()); + return; + } + const buf = Buffer.concat(chunks); + const out = new Float32Array(Math.floor(buf.length / 4)); + for (let i = 0; i < out.length; i += 1) out[i] = buf.readFloatLE(i * 4); + resolve(out); + }); + }); +} + +function pickOnsetPeaks(pcm: PcmAnalysis): Array<{ t: number; strength: number }> { + const threshold = quantile(pcm.onset, 0.76); + const peaks: Array<{ t: number; strength: number }> = []; + const minDistanceSec = 0.22; + for (let i = 1; i < pcm.onset.length - 1; i += 1) { + const cur = pcm.onset[i]; + if (cur < threshold || cur < pcm.onset[i - 1] || cur < pcm.onset[i + 1]) continue; + const t = pcm.times[i]; + if (peaks.length && t - peaks[peaks.length - 1].t < minDistanceSec) { + if (cur > peaks[peaks.length - 1].strength) peaks[peaks.length - 1] = { t, strength: cur }; + } else { + peaks.push({ t, strength: cur }); + } + } + return peaks; +} + +function estimateTempo(pcm: PcmAnalysis, cutDensity: string): { bpm: number; score: number } { + const fallbackBpm = cutDensity === 'high' ? 128 : cutDensity === 'medium' ? 112 : 92; + let best = { bpm: fallbackBpm, score: 0 }; + for (let bpm = 72; bpm <= 176; bpm += 1) { + const lag = Math.max(1, Math.round((60 / bpm) / pcm.hopSec)); + let score = 0; + let weight = 0; + for (let i = lag; i < pcm.onset.length; i += 1) { + score += pcm.onset[i] * pcm.onset[i - lag]; + weight += pcm.onset[i]; + } + const normalized = weight ? score / weight : 0; + const densityBias = 1 - Math.min(0.25, Math.abs(bpm - fallbackBpm) / 420); + const candidate = normalized * densityBias; + if (candidate > best.score) best = { bpm, score: candidate }; + } + return { bpm: best.bpm, score: round(best.score) }; +} + +function estimateBeatOffset(peaks: Array<{ t: number; strength: number }>, step: number): number { + const bins = new Map(); + const binSize = 0.025; + peaks.forEach((peak) => { + const phase = ((peak.t % step) + step) % step; + const bin = Math.round(phase / binSize); + bins.set(bin, (bins.get(bin) ?? 0) + peak.strength); + }); + let bestBin = 0; + let bestScore = -Infinity; + for (const [bin, score] of bins.entries()) { + if (score > bestScore) { + bestBin = bin; + bestScore = score; + } + } + return round(Math.min(step, Math.max(0, bestBin * binSize))); +} + +function buildBeatTimes(offsetSec: number, step: number, durationSec: number): number[] { + const beats: number[] = []; + for (let t = offsetSec; t <= durationSec + step * 0.5; t += step) beats.push(round(Math.min(durationSec, t))); + for (let t = offsetSec - step; t >= 0; t -= step) beats.unshift(round(t)); + return Array.from(new Set(beats)).filter((beat) => beat >= 0 && beat <= durationSec); +} + +function estimateDownbeatPhase(beatsSec: number[], pcm: PcmAnalysis): number { + const scores = [0, 0, 0, 0]; + beatsSec.forEach((beat, index) => { + const energy = valueAtTime(pcm.rms, pcm.times, beat) + valueAtTime(pcm.onset, pcm.times, beat) * 1.6; + scores[index % 4] += energy; + }); + return scores.reduce((best, score, index) => (score > scores[best] ? index : best), 0); +} + +function inferSectionsFromPhrases(opts: { + durationSec: number; + phraseBoundariesSec: number[]; + downbeatsSec: number[]; + pcm: PcmAnalysis; +}): MusicSection[] { + const boundaries = [0, ...opts.phraseBoundariesSec.filter((time) => time > 0.4 && time < opts.durationSec - 0.4), opts.durationSec]; + const uniqueBoundaries = Array.from(new Set(boundaries.map(round))).sort((a, b) => a - b); + if (uniqueBoundaries.length < 3) { + return MusicSection.array().parse([{ + startSec: 0, + endSec: opts.durationSec, + kind: 'unknown', + confidence: 0.25, + downbeatsSec: opts.downbeatsSec, + }]); + } + const energies = uniqueBoundaries.slice(0, -1).map((start, index) => sectionEnergy(opts.pcm, start, uniqueBoundaries[index + 1])); + let dropIndex = Math.max(1, Math.floor((uniqueBoundaries.length - 2) * 0.55)); + let dropScore = -Infinity; + for (let i = 1; i < energies.length; i += 1) { + const start = uniqueBoundaries[i]; + if (start < opts.durationSec * 0.18 || start > opts.durationSec * 0.86) continue; + const jump = energies[i] - energies[i - 1]; + const score = jump * 1.4 + energies[i] * 0.6 + valueAtTime(opts.pcm.onset, opts.pcm.times, start); + if (score > dropScore) { + dropScore = score; + dropIndex = i; + } + } + const sections = uniqueBoundaries.slice(0, -1).map((start, index) => { + const end = uniqueBoundaries[index + 1]; + const kind = + index === 0 + ? 'intro' + : index < dropIndex + ? 'build' + : index === dropIndex + ? 'drop' + : index >= uniqueBoundaries.length - 2 + ? 'outro' + : 'chorus'; + return { + startSec: start, + endSec: end, + kind, + confidence: kind === 'drop' ? 0.68 : 0.52, + downbeatsSec: opts.downbeatsSec.filter((time) => time >= start && time < end), + }; + }); + return MusicSection.array().parse(sections.filter((section) => section.endSec - section.startSec > 0.2)); +} + +function sectionEnergy(pcm: PcmAnalysis, startSec: number, endSec: number): number { + let sum = 0; + let count = 0; + for (let i = 0; i < pcm.times.length; i += 1) { + if (pcm.times[i] < startSec || pcm.times[i] >= endSec) continue; + sum += pcm.rms[i]; + count += 1; + } + return count ? sum / count : 0; +} + +function valueAtTime(values: number[], times: number[], timeSec: number): number { + if (!values.length) return 0; + let best = 0; + for (let i = 1; i < times.length; i += 1) { + if (Math.abs(times[i] - timeSec) < Math.abs(times[best] - timeSec)) best = i; + } + return values[best] ?? 0; +} + +export async function detectAudioOnsets( + sourcePath: string, + durationSec: number, + maxOnsets = 24, +): Promise { + const samples = await sampleAudioEnergy(sourcePath); + if (samples.length < 4) return []; + return pickEnergyPeakSamples(samples, durationSec) + .slice(0, maxOnsets) + .map((peak) => ({ + timeSec: peak.t, + relativeTime: round(peak.t / Math.max(0.1, durationSec)), + strength: peak.strength, + energyDb: peak.rms, + })); +} + +function sampleAudioEnergy(file: string): Promise> { + return new Promise((resolve) => { + const p = spawn('ffmpeg', [ + '-hide_banner', + '-nostats', + '-i', + file, + '-af', + 'aresample=8000,asetnsamples=n=4000,astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level', + '-f', + 'null', + '-', + ]); + let err = ''; + p.stderr.on('data', (d) => { + err += d.toString(); + }); + p.on('error', () => resolve([])); + p.on('close', () => { + const out: Array<{ t: number; rms: number }> = []; + let currentT = 0; + for (const line of err.split('\n')) { + const pts = /pts_time:([0-9.]+)/.exec(line); + if (pts) currentT = Number(pts[1]); + const rms = /lavfi\.astats\.Overall\.RMS_level=(-?[0-9.]+)/.exec(line); + if (rms && Number.isFinite(currentT)) out.push({ t: currentT, rms: Number(rms[1]) }); + } + resolve(out); + }); + }); +} + +function pickEnergyPeaks(samples: Array<{ t: number; rms: number }>, durationSec: number): number[] { + return pickEnergyPeakSamples(samples, durationSec).map((peak) => peak.t); +} + +function pickEnergyPeakSamples( + samples: Array<{ t: number; rms: number }>, + durationSec: number, +): Array<{ t: number; rms: number; strength: 'weak' | 'medium' | 'strong' }> { + const finite = samples.filter((s) => Number.isFinite(s.rms)); + if (!finite.length) return []; + const sorted = finite.map((s) => s.rms).sort((a, b) => a - b); + const threshold = sorted[Math.floor(sorted.length * 0.76)] ?? sorted[sorted.length - 1]; + const highThreshold = sorted[Math.floor(sorted.length * 0.9)] ?? threshold; + const midThreshold = sorted[Math.floor(sorted.length * 0.82)] ?? threshold; + const peaks: Array<{ t: number; rms: number; strength: 'weak' | 'medium' | 'strong' }> = []; + for (let i = 1; i < finite.length - 1; i += 1) { + const prev = finite[i - 1]; + const cur = finite[i]; + const next = finite[i + 1]; + if (cur.rms >= threshold && cur.rms >= prev.rms && cur.rms >= next.rms) { + if (!peaks.length || cur.t - peaks[peaks.length - 1].t > 0.28) { + peaks.push({ + t: round(cur.t), + rms: round(cur.rms), + strength: cur.rms >= highThreshold ? 'strong' : cur.rms >= midThreshold ? 'medium' : 'weak', + }); + } + } + } + return peaks.filter((peak) => peak.t >= 0 && peak.t <= durationSec); +} + +function smooth(values: number[], radius: number): number[] { + return values.map((_, index) => { + let sum = 0; + let count = 0; + for (let i = Math.max(0, index - radius); i <= Math.min(values.length - 1, index + radius); i += 1) { + sum += values[i]; + count += 1; + } + return count ? sum / count : 0; + }); +} + +function normalize(values: number[]): number[] { + const finite = values.filter(Number.isFinite); + if (!finite.length) return values.map(() => 0); + const min = Math.min(...finite); + const max = Math.max(...finite); + const span = max - min; + if (span <= 1e-9) return values.map(() => 0); + return values.map((value) => (Number.isFinite(value) ? (value - min) / span : 0)); +} + +function quantile(values: number[], q: number): number { + const finite = values.filter(Number.isFinite).sort((a, b) => a - b); + if (!finite.length) return 0; + return finite[Math.max(0, Math.min(finite.length - 1, Math.floor(finite.length * q)))]; +} + +function fallbackStep(density: string): number { + return 60 / (density === 'high' ? 128 : density === 'medium' ? 112 : 92); +} + +function round(n: number): number { + return Math.round(n * 1000) / 1000; +} diff --git a/apps/api/src/media/templateDetect.ts b/apps/api/src/media/templateDetect.ts new file mode 100644 index 0000000..3549c9d --- /dev/null +++ b/apps/api/src/media/templateDetect.ts @@ -0,0 +1,230 @@ +import { spawn } from 'node:child_process'; +import type { SampleMetadata } from '../core/sample'; +import { TemplateProfile, type TemplateMotionDirection, type TemplateMotionEvent, type TemplateViewport } from '../core/template'; +import { detectAudioOnsets } from './beatDetect'; +import { detectScenes } from './scenes'; +import { dedupeSorted, round } from './util'; + +const MOTION_SCENE_THRESHOLD = 0.05; +const MIN_MOTION_GAP_SEC = 0.32; + +export interface DetectTemplateProfileInput { + sampleId: string; + sourcePath: string; + metadata: SampleMetadata; + hardCuts: number[]; +} + +interface CropWindow { + w: number; + h: number; + x: number; + y: number; +} + +export async function detectTemplateProfile(input: DetectTemplateProfileInput) { + const { metadata } = input; + const durationSec = Math.max(0.1, metadata.durationSec); + const [crop, lowSceneCuts, audioOnsets] = await Promise.all([ + detectCropWindow(input.sourcePath), + detectScenes(input.sourcePath, MOTION_SCENE_THRESHOLD).catch(() => []), + metadata.hasAudio ? detectAudioOnsets(input.sourcePath, durationSec).catch(() => []) : Promise.resolve([]), + ]); + + const viewport = viewportFromCrop(crop, metadata); + const areaRatio = viewport.width * viewport.height; + const sourceAspect = `${metadata.width}:${metadata.height}`; + const sourceRatio = metadata.width / Math.max(1, metadata.height); + const hardCuts = input.hardCuts; + const motionCuts = dedupeSorted(lowSceneCuts, MIN_MOTION_GAP_SEC) + .filter((t) => !hardCuts.some((cut) => Math.abs(cut - t) < 0.16)) + .filter((t) => t > 0.12 && t < durationSec - 0.12); + const pulseDensity = motionCuts.length / durationSec; + const hasMatte = areaRatio < 0.88 || viewport.x > 0.04 || viewport.y > 0.04; + const cameraCarousel = looksLikeCameraCarousel({ + viewport, + sourceRatio, + motionCuts, + hardCuts, + durationSec, + }); + const layoutPreset = cameraCarousel + ? 'camera_carousel' + : hasMatte + ? 'cinematic_matte' + : sourceRatio > 1.12 + ? 'letterbox_frame' + : sourceRatio < 0.82 + ? 'full_bleed' + : 'unknown'; + const internalMotionIntensity = pulseDensity > 1.05 ? 'high' : pulseDensity > 0.35 ? 'medium' : 'low'; + const hasMaskReveals = + cameraCarousel || + (motionCuts.length >= 3 && + (layoutPreset === 'cinematic_matte' || layoutPreset === 'letterbox_frame' || hardCuts.length <= motionCuts.length / 3)); + const hasViewportSlides = cameraCarousel || layoutPreset === 'cinematic_matte' || (sourceRatio > 1.12 && motionCuts.length >= 2); + const direction = inferDirection(layoutPreset, sourceRatio, hasViewportSlides); + const preferredMotionPreset = internalMotionIntensity === 'high' + ? audioOnsets.length >= 4 ? 'beat_pulse' : 'reveal_pan' + : internalMotionIntensity === 'medium' + ? 'reveal_pan' + : 'ken_burns_in'; + const preferredTransitionPreset = hasMaskReveals + ? internalMotionIntensity === 'high' ? 'snap_cut' : 'whip_cut' + : 'cut'; + + const motionEvents = motionCuts.slice(0, 16).map((timeSec, index): TemplateMotionEvent => { + const nearest = nearestOnset(timeSec, audioOnsets); + return { + kind: cameraCarousel ? 'carousel_slide' : hasMaskReveals ? 'mask_reveal' : 'internal_motion', + timeSec: round(timeSec), + relativeTime: round(timeSec / durationSec), + strength: index === 0 || nearest?.strength === 'strong' ? 'strong' : internalMotionIntensity === 'high' ? 'medium' : 'weak', + direction, + nearestOnsetSec: nearest && Math.abs(nearest.timeSec - timeSec) <= 0.2 ? nearest.timeSec : undefined, + description: cameraCarousel + ? '低阈值画面变化,推断为固定相机外壳内的横向素材滑动' + : hasMaskReveals ? '低阈值画面变化,推断为模板内遮罩/滑动画面变化' : '低阈值画面变化,推断为镜头内运动', + }; + }); + const onsetEvents = audioOnsets.slice(0, 12).map((onset): TemplateMotionEvent => ({ + kind: 'audio_onset', + timeSec: onset.timeSec, + relativeTime: onset.relativeTime, + strength: onset.strength, + direction: 'unknown', + description: '真实音频能量峰,可作为模板运动或转场锚点', + })); + + return TemplateProfile.parse({ + id: `tpl_${input.sampleId}`, + source: 'user_sample', + durationSec, + sourceAspect, + targetCanvasAspect: '9:16', + layoutPreset, + frameStyle: { + backgroundColor: '#050505', + matte: cameraCarousel || layoutPreset === 'cinematic_matte' || layoutPreset === 'letterbox_frame', + roundedMask: cameraCarousel || layoutPreset === 'cinematic_matte', + borderColor: hasMatte ? 'rgba(255,255,255,0.12)' : undefined, + labelStyle: cameraCarousel + ? 'camera_ui' + : layoutPreset === 'cinematic_matte' || layoutPreset === 'letterbox_frame' ? 'film_code' : 'none', + viewport, + }, + motionLanguage: { + internalMotionIntensity, + hasMaskReveals, + hasViewportSlides, + preferredMotionPreset, + preferredTransitionPreset, + notes: [ + `低阈值画面变化 ${motionCuts.length} 个,硬切 ${hardCuts.length} 个。`, + audioOnsets.length ? `真实音频 onset ${audioOnsets.length} 个。` : '未检测到可用真实音频 onset。', + cameraCarousel + ? '样例更像固定相机外壳内的横向 carousel 滑动模板。' + : hasMaskReveals ? '样例更像模板内运动/遮罩揭示,而不是普通硬切快剪。' : '样例以内在运镜或普通画面变化为主。', + ], + }, + audioOnsets, + events: [...motionEvents, ...onsetEvents].sort((a, b) => a.timeSec - b.timeSec), + strategySummary: + cameraCarousel + ? '迁移时保留固定相机/胶片外壳,把多段新素材拼成横向 carousel strip,并按真实音频 onset 推进滑动。' + : layoutPreset === 'cinematic_matte' || layoutPreset === 'letterbox_frame' + ? '迁移时保留黑底横版画幅框架,把新素材放入模板 viewport,并把内部运动/遮罩变化吸附到音频 onset。' + : '迁移时保留样例的内部运动密度,并把主要视觉变化吸附到音频 onset 或 beat grid。', + renderHints: [ + layoutPreset, + preferredMotionPreset, + preferredTransitionPreset, + cameraCarousel ? 'carousel_strip' : 'single_viewport', + hasMaskReveals ? 'mask_reveal' : 'no_mask_reveal', + ], + }); +} + +function looksLikeCameraCarousel(opts: { + viewport: TemplateViewport; + sourceRatio: number; + motionCuts: number[]; + hardCuts: number[]; + durationSec: number; +}) { + const viewportLooksInset = + opts.viewport.width >= 0.68 && + opts.viewport.width <= 0.96 && + opts.viewport.height >= 0.45 && + opts.viewport.height <= 0.75 && + opts.viewport.x >= 0.03 && + opts.viewport.y >= 0.12; + const motionIsInternal = opts.motionCuts.length >= 6 && opts.hardCuts.length <= Math.max(1, opts.motionCuts.length / 5); + const shortTemplate = opts.durationSec <= 8.5; + return opts.sourceRatio > 1.25 && viewportLooksInset && motionIsInternal && shortTemplate; +} + +function detectCropWindow(path: string): Promise { + return new Promise((resolve) => { + const p = spawn('ffmpeg', [ + '-hide_banner', + '-nostats', + '-i', path, + '-vf', 'cropdetect=24:16:0', + '-frames:v', '120', + '-f', 'null', + '-', + ]); + let err = ''; + p.stderr.on('data', (d) => { + err += d.toString(); + }); + p.on('error', () => resolve(null)); + p.on('close', () => { + const matches = [...err.matchAll(/crop=(\d+):(\d+):(\d+):(\d+)/g)]; + const last = matches.at(-1); + if (!last) return resolve(null); + resolve({ + w: Number(last[1]), + h: Number(last[2]), + x: Number(last[3]), + y: Number(last[4]), + }); + }); + }); +} + +function viewportFromCrop(crop: CropWindow | null, metadata: SampleMetadata) { + if (!crop || crop.w <= 0 || crop.h <= 0) { + return { + aspectRatio: `${metadata.width}:${metadata.height}`, + x: 0, + y: 0, + width: 1, + height: 1, + }; + } + return { + aspectRatio: `${crop.w}:${crop.h}`, + x: round(crop.x / metadata.width), + y: round(crop.y / metadata.height), + width: round(crop.w / metadata.width), + height: round(crop.h / metadata.height), + }; +} + +function inferDirection( + layoutPreset: string, + sourceRatio: number, + hasViewportSlides: boolean, +): TemplateMotionDirection { + if (!hasViewportSlides) return 'unknown'; + if (layoutPreset === 'cinematic_matte' || sourceRatio > 1.12) return 'left'; + return 'mixed'; +} + +function nearestOnset(timeSec: number, onsets: Array<{ timeSec: number; strength: 'weak' | 'medium' | 'strong' }>) { + return onsets + .map((onset) => ({ ...onset, distance: Math.abs(onset.timeSec - timeSec) })) + .sort((a, b) => a.distance - b.distance)[0]; +} diff --git a/apps/api/src/render/__tests__/audioPostprocess.test.ts b/apps/api/src/render/__tests__/audioPostprocess.test.ts new file mode 100644 index 0000000..d8a4d13 --- /dev/null +++ b/apps/api/src/render/__tests__/audioPostprocess.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { buildAudioNormalizeFilter } from '../audioPostprocess'; + +describe('audio postprocess', () => { + it('builds loudnorm and short fade filter for final output', () => { + expect(buildAudioNormalizeFilter({ durationSec: 14 })).toBe( + 'loudnorm=I=-15:TP=-1:LRA=11,afade=t=in:st=0:d=0.18,afade=t=out:st=13.720:d=0.28', + ); + }); +}); diff --git a/apps/api/src/render/audioPostprocess.ts b/apps/api/src/render/audioPostprocess.ts new file mode 100644 index 0000000..b67f21d --- /dev/null +++ b/apps/api/src/render/audioPostprocess.ts @@ -0,0 +1,72 @@ +import { existsSync, renameSync } from 'node:fs'; +import { dirname, extname, join, basename } from 'node:path'; +import { ffprobeDuration, runFfmpeg } from './ffmpeg'; + +export interface AudioNormalizeOptions { + targetI?: number; + truePeak?: number; + loudnessRange?: number; + fadeInSec?: number; + fadeOutSec?: number; +} + +export async function normalizeOutputAudio( + outFile: string, + durationSec: number, + opts: AudioNormalizeOptions = {}, +): Promise<{ durationSec: number; warning: string }> { + const targetI = opts.targetI ?? -15; + const truePeak = opts.truePeak ?? -1; + const loudnessRange = opts.loudnessRange ?? 11; + const fadeInSec = opts.fadeInSec ?? 0.18; + const fadeOutSec = opts.fadeOutSec ?? 0.28; + const ext = extname(outFile) || '.mp4'; + const tmp = join(dirname(outFile), `${basename(outFile, ext)}.audio-normalized-${Date.now()}${ext}`); + const audioFilter = buildAudioNormalizeFilter({ + durationSec, + targetI, + truePeak, + loudnessRange, + fadeInSec, + fadeOutSec, + }); + + await runFfmpeg( + [ + '-i', outFile, + '-af', audioFilter, + '-c:v', 'copy', + '-c:a', 'aac', + '-movflags', '+faststart', + tmp, + ], + 'audio-normalize', + ); + if (!existsSync(tmp)) throw new Error('audio-normalize did not produce output'); + renameSync(tmp, outFile); + return { + durationSec: await ffprobeDuration(outFile), + warning: `音频已归一化到约 ${targetI} LUFS / TP ${truePeak} dB,并添加片头片尾淡入淡出。`, + }; +} + +export function buildAudioNormalizeFilter(opts: { + durationSec: number; + targetI?: number; + truePeak?: number; + loudnessRange?: number; + fadeInSec?: number; + fadeOutSec?: number; +}): string { + const targetI = opts.targetI ?? -15; + const truePeak = opts.truePeak ?? -1; + const loudnessRange = opts.loudnessRange ?? 11; + const fadeInSec = opts.fadeInSec ?? 0.18; + const fadeOutSec = opts.fadeOutSec ?? 0.28; + const fadeOutStart = Math.max(0, opts.durationSec - fadeOutSec); + return [ + `loudnorm=I=${targetI}:TP=${truePeak}:LRA=${loudnessRange}`, + `afade=t=in:st=0:d=${fadeInSec}`, + `afade=t=out:st=${fadeOutStart.toFixed(3)}:d=${fadeOutSec}`, + ].join(','); +} diff --git a/apps/api/src/render/ffmpeg.ts b/apps/api/src/render/ffmpeg.ts index c3b7a8b..a20d20f 100644 --- a/apps/api/src/render/ffmpeg.ts +++ b/apps/api/src/render/ffmpeg.ts @@ -37,3 +37,28 @@ export function ffprobeDuration(file: string): Promise { ); }); } + +/** 判断文件是否至少包含一条可用音频流。 */ +export function ffprobeHasAudio(file: string): Promise { + return new Promise((resolve, reject) => { + const p = spawn('ffprobe', [ + '-v', + 'error', + '-select_streams', + 'a:0', + '-show_entries', + 'stream=codec_type', + '-of', + 'csv=p=0', + file, + ]); + let out = ''; + let err = ''; + p.stdout.on('data', (d) => (out += d.toString())); + p.stderr.on('data', (d) => (err += d.toString())); + p.on('error', reject); + p.on('close', (code) => + code === 0 ? resolve(out.trim().length > 0) : reject(new Error(err)), + ); + }); +} diff --git a/apps/api/src/render/motionProfile.ts b/apps/api/src/render/motionProfile.ts new file mode 100644 index 0000000..b55c817 --- /dev/null +++ b/apps/api/src/render/motionProfile.ts @@ -0,0 +1,170 @@ +import { spawnSync } from 'node:child_process'; + +export type VideoStabilizationMode = boolean | 'auto'; + +export interface VideoMotionProfile { + avgY: number; + highlightFrameRatio: number; + avgFrameDiff: number; + maxFrameDiff: number; +} + +export interface StableSourceWindow { + sourceInSec: number; + adjusted: boolean; + profile: VideoMotionProfile | null; +} + +export function selectStableSourceWindow( + path: string, + opts: { + sourceInSec?: number; + durationSec: number; + assetDurationSec?: number; + }, +): StableSourceWindow { + const assetDuration = opts.assetDurationSec ?? ffprobeDurationSync(path); + const itemDuration = Math.max(0.1, opts.durationSec); + const maxStart = Math.max(0, (assetDuration ?? itemDuration) - itemDuration); + const requestedStart = round(clamp(opts.sourceInSec ?? 0, 0, maxStart)); + const currentProfile = probeVideoMotionProfile(path, { + startSec: requestedStart, + durationSec: itemDuration, + }); + + if (!assetDuration || assetDuration <= itemDuration + 0.5 || !currentProfile) { + return { sourceInSec: requestedStart, adjusted: false, profile: currentProfile }; + } + + const candidates = uniqueStarts([ + requestedStart, + requestedStart - itemDuration, + requestedStart + itemDuration, + requestedStart - 1, + requestedStart + 1, + ].map((start) => round(clamp(start, 0, maxStart)))); + + let best = { startSec: requestedStart, profile: currentProfile, score: motionScore(currentProfile) }; + for (const startSec of candidates) { + if (Math.abs(startSec - requestedStart) < 0.05) continue; + const profile = probeVideoMotionProfile(path, { startSec, durationSec: itemDuration }); + if (!profile) continue; + const score = motionScore(profile); + if (score < best.score) best = { startSec, profile, score }; + } + + const currentScore = motionScore(currentProfile); + const improvement = currentScore - best.score; + const shouldAdjust = Math.abs(best.startSec - requestedStart) >= 0.05 && improvement > 0.35 && best.score <= currentScore * 0.78; + + return { + sourceInSec: shouldAdjust ? best.startSec : requestedStart, + adjusted: shouldAdjust, + profile: shouldAdjust ? best.profile : currentProfile, + }; +} + +export function probeVideoMotionProfile( + path: string, + opts: { startSec?: number; durationSec?: number } = {}, +): VideoMotionProfile | null { + const sampleDuration = Math.max(0.2, Math.min(2, opts.durationSec ?? 2)); + const brightness = probeSignalStats(path, { + startSec: opts.startSec, + durationSec: sampleDuration, + filter: 'fps=2,scale=160:-1,signalstats,metadata=print:file=-', + }); + const motion = probeSignalStats(path, { + startSec: opts.startSec, + durationSec: sampleDuration, + filter: 'fps=8,scale=160:-1,tblend=all_mode=difference,signalstats,metadata=print:file=-', + }); + + if (!brightness || !motion) return null; + return { + avgY: brightness.yAvg, + highlightFrameRatio: brightness.highlightFrameRatio, + avgFrameDiff: motion.yAvg, + maxFrameDiff: motion.yMax, + }; +} + +export function stabilizationSkipReason(profile: VideoMotionProfile): 'dark_high_reflection' | 'foreground_motion' | null { + if (profile.avgY < 65 && profile.highlightFrameRatio >= 0.5) return 'dark_high_reflection'; + if (profile.avgFrameDiff >= 3.2 && profile.maxFrameDiff >= 6) return 'foreground_motion'; + if (profile.avgFrameDiff >= 4.2) return 'foreground_motion'; + return null; +} + +function probeSignalStats( + path: string, + opts: { startSec?: number; durationSec: number; filter: string }, +): { yAvg: number; yMax: number; highlightFrameRatio: number } | null { + const result = spawnSync( + 'ffmpeg', + [ + '-hide_banner', + '-loglevel', + 'error', + ...(opts.startSec && opts.startSec > 0 ? ['-ss', String(opts.startSec)] : []), + '-t', + String(opts.durationSec), + '-i', + path, + '-vf', + opts.filter, + '-an', + '-f', + 'null', + '-', + ], + { encoding: 'utf8' }, + ); + + if (result.status !== 0 && !result.stdout && !result.stderr) return null; + const text = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + const yAvg = valuesForMetadata(text, 'YAVG'); + const yMax = valuesForMetadata(text, 'YMAX'); + if (!yAvg.length || !yMax.length) return null; + return { + yAvg: mean(yAvg), + yMax: Math.max(...yMax), + highlightFrameRatio: yMax.filter((value) => value >= 245).length / yMax.length, + }; +} + +function ffprobeDurationSync(path: string): number | null { + const result = spawnSync( + 'ffprobe', + ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', path], + { encoding: 'utf8' }, + ); + const duration = Number(result.stdout?.trim()); + return Number.isFinite(duration) && duration > 0 ? duration : null; +} + +function valuesForMetadata(text: string, key: string): number[] { + return [...text.matchAll(new RegExp(`lavfi\\.signalstats\\.${key}=([0-9.]+)`, 'g'))] + .map((match) => Number(match[1])) + .filter(Number.isFinite); +} + +function motionScore(profile: VideoMotionProfile): number { + return profile.avgFrameDiff + profile.maxFrameDiff * 0.18; +} + +function uniqueStarts(values: number[]): number[] { + return [...new Set(values.map((value) => value.toFixed(2)))].map(Number); +} + +function mean(values: number[]): number { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function round(value: number): number { + return Math.round(value * 1000) / 1000; +} diff --git a/apps/api/src/render/remotion/Root.tsx b/apps/api/src/render/remotion/Root.tsx new file mode 100644 index 0000000..9e23d26 --- /dev/null +++ b/apps/api/src/render/remotion/Root.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Composition, registerRoot } from 'remotion'; +import { TimelineComposition } from './TimelineComposition'; +import type { RemotionTimelineProps } from './types'; + +const defaultProps: RemotionTimelineProps = { + width: 1080, + height: 1920, + fps: 30, + durationInFrames: 30, + backgroundColor: '#111827', + items: [], + subtitles: [], +}; + +function Root() { + return ( + ({ + width: props.width, + height: props.height, + fps: props.fps, + durationInFrames: Math.max(1, props.durationInFrames), + })} + /> + ); +} + +registerRoot(Root); diff --git a/apps/api/src/render/remotion/TimelineComposition.tsx b/apps/api/src/render/remotion/TimelineComposition.tsx new file mode 100644 index 0000000..fd290f8 --- /dev/null +++ b/apps/api/src/render/remotion/TimelineComposition.tsx @@ -0,0 +1,1318 @@ +import React from 'react'; +import { + AbsoluteFill, + Audio, + Img, + interpolate, + OffthreadVideo, + Sequence, + staticFile, + useCurrentFrame, +} from 'remotion'; +import { entryTransitionTiming } from './animation'; +import type { RemotionTimelineItem, RemotionTimelineProps } from './types'; +import { isCarouselTemplateProfile } from '../../core/template'; + +type TemplateProfile = RemotionTimelineProps['templateProfile']; + +export function TimelineComposition(props: RemotionTimelineProps) { + const visualItems = props.items.filter((item) => item.track !== 'audio'); + const audioItems = props.items.filter((item) => item.track === 'audio' && item.staticPath); + const useCarouselTemplate = isCarouselTemplateProfile(props.templateProfile); + + return ( + + {useCarouselTemplate ? ( + + + + ) : visualItems.map((item, idx) => { + const entryFrames = idx === 0 ? 0 : entryTransitionTiming(item, props.fps).frames; + const exitFrames = visualItems[idx + 1] + ? entryTransitionTiming(visualItems[idx + 1], props.fps).frames + : 0; + const sequenceFrom = Math.max(0, item.startFrame - entryFrames); + const shiftedEntry = item.startFrame - sequenceFrom; + return ( + + + + ); + })} + {props.subtitles.map((cue) => ( + + + + ))} + {audioItems.map((item) => ( + + + ))} + + ); +} + +function VisualItem({ + item, + entryFrames, + exitFrames, + templateProfile, + canvasWidth, + canvasHeight, +}: { + item: RemotionTimelineItem; + entryFrames: number; + exitFrames: number; + templateProfile?: TemplateProfile; + canvasWidth: number; + canvasHeight: number; +}) { + const frame = useCurrentFrame(); + const contentFrame = frame - entryFrames; + const progress = Math.max(0, Math.min(1, contentFrame / Math.max(1, item.durationInFrames - 1))); + const style = animatedVisualStyle(item, progress, frame, entryFrames, exitFrames); + + if (item.sourceKind === 'text_card') { + return ; + } + if (item.sourceKind === 'image' && item.staticPath) { + return ( + + + + ); + } + if (item.sourceKind === 'video' && item.staticPath) { + return ( + + + + ); + } + return ( + + + + ); +} + +function CarouselTemplate({ + items, + templateProfile, + canvasWidth, + canvasHeight, + fps, +}: { + items: RemotionTimelineItem[]; + templateProfile: NonNullable; + canvasWidth: number; + canvasHeight: number; + fps: number; +}) { + const frame = useCurrentFrame(); + const frameBox = cameraCarouselFrameBox(templateProfile, canvasWidth, canvasHeight); + const viewport = cameraCarouselViewportBox(templateProfile, canvasWidth, canvasHeight); + const slideItems = carouselSlideItems(items, templateProfile, fps); + return ( + + +
+ {slideItems.map((item, index) => ( + + ))} +
+
+ ); +} + +function carouselSlideItems( + items: RemotionTimelineItem[], + templateProfile: NonNullable, + fps: number, +): RemotionTimelineItem[] { + if (items.length !== 1) return items; + const source = items[0]; + const targetCount = Math.max(3, Math.min(5, Math.round(templateProfile.durationSec / 1.15))); + const totalFrames = source.durationInFrames; + const segmentFrames = Math.max(12, Math.floor(totalFrames / targetCount)); + + return Array.from({ length: targetCount }, (_, index) => { + const startFrame = index * segmentFrames; + const isLast = index === targetCount - 1; + const durationInFrames = Math.max(12, isLast ? totalFrames - startFrame : segmentFrames); + const trimBeforeFrames = source.sourceKind === 'video' + ? (source.trimBeforeFrames ?? 0) + Math.floor((source.durationInFrames * index) / targetCount) + : source.trimBeforeFrames; + return { + ...source, + id: `${source.id}_carousel_${index + 1}`, + startFrame, + durationInFrames, + startSec: startFrame / fps, + endSec: (startFrame + durationInFrames) / fps, + trimBeforeFrames, + }; + }); +} + +function CarouselSlide({ + item, + index, + frame, + fps, +}: { + item: RemotionTimelineItem; + index: number; + frame: number; + fps: number; +}) { + const transitionFrames = Math.max(10, Math.min(Math.round(fps * 0.42), Math.floor(item.durationInFrames * 0.38))); + const start = item.startFrame; + const end = item.startFrame + item.durationInFrames; + const visibleFrom = index === 0 ? start : start - transitionFrames; + const visibleTo = end + transitionFrames; + if (frame < visibleFrom || frame > visibleTo) return null; + + const localProgress = Math.max(0, Math.min(1, (frame - start) / Math.max(1, item.durationInFrames - 1))); + const enterStart = index === 0 ? start : start - transitionFrames; + const enterEnd = start + transitionFrames; + const exitStart = Math.max(start, end - transitionFrames); + const exitEnd = end + transitionFrames; + const x = frame < enterEnd + ? interpolate(frame, [enterStart, enterEnd], [index === 0 ? 52 : 112, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) + : frame > exitStart + ? interpolate(frame, [exitStart, exitEnd], [0, -112], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) + : 0; + const opacity = frame < enterEnd + ? interpolate(frame, [enterStart, enterEnd], [index === 0 ? 0.55 : 0.82, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) + : frame > exitStart + ? interpolate(frame, [exitStart, exitEnd], [1, 0.72], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) + : 1; + + return ( +
+ +
+ ); +} + +function CarouselSlideMedia({ item, progress }: { item: RemotionTimelineItem; progress: number }) { + const style = baseMotionStyle(item, progress); + if (item.sourceKind === 'image' && item.staticPath) { + return ; + } + if (item.sourceKind === 'video' && item.staticPath) { + return ( + + ); + } + if (item.sourceKind === 'text_card') { + return ( + +
+ {wrapLines(item.text ?? '').join('\n')} +
+
+ ); + } + return ; +} + +function CameraCarouselChrome({ + templateProfile, + frameBox, + viewport, +}: { + templateProfile: NonNullable; + frameBox: { left: number; top: number; width: number; height: number }; + viewport: { left: number; top: number; width: number; height: number }; +}) { + return ( + <> +
+
+
+
+ GLIMMER · LOAD FILM · TODAY +
+
+
+ {Math.round(templateProfile.durationSec * 10) / 10}s +
+ + ); +} + +function MediaStage({ + item, + templateProfile, + canvasWidth, + canvasHeight, + progress, + children, +}: { + item: RemotionTimelineItem; + templateProfile?: TemplateProfile; + canvasWidth: number; + canvasHeight: number; + progress: number; + children: React.ReactNode; +}) { + const overlay = item.overlayText + ? + : null; + if (!usesTemplateFrame(templateProfile)) { + if (usesViewportFrame(item)) { + const isReferenceViewport = item.framePolicy === 'reference_viewport'; + return ( + + {cloneMediaChild(children, viewportBackgroundStyle(item))} +
+ {cloneMediaChild(children, viewportForegroundStyle(item))} +
+ {isReferenceViewport ?
: null} + {overlay} + + ); + } + return ( + + {children} + {overlay} + + ); + } + const viewport = templateViewportStyle(templateProfile, canvasWidth, canvasHeight, progress); + return ( + + +
{children}
+ {overlay} +
+ ); +} + +function TemplateChrome({ + templateProfile, + canvasWidth, + canvasHeight, +}: { + templateProfile: NonNullable; + canvasWidth: number; + canvasHeight: number; +}) { + if (templateProfile.frameStyle.labelStyle === 'none') return null; + const viewport = templateViewportBox(templateProfile, canvasWidth, canvasHeight); + const lineWidth = Math.min(520, canvasWidth * 0.48); + return ( + <> +
+
+ 00:00 / {Math.round(templateProfile.durationSec * 10) / 10}s +
+
+
+ - - - +
+ + ); +} + +function TextCard({ item, style }: { item: RemotionTimelineItem; style: React.CSSProperties }) { + const accent = cardAccent(item); + return ( + + +
+ {accent ?
{accent.text}
: null} + {wrapLines(item.text ?? '').map((line, idx) => ( +
{line}
+ ))} +
+
+ ); +} + +function Placeholder({ + item, + style, + compact = false, +}: { + item: RemotionTimelineItem; + style: React.CSSProperties; + compact?: boolean; +}) { + const frame = useCurrentFrame(); + const opacity = interpolate(frame, [0, 10], [0.72, 1], { extrapolateRight: 'clamp' }); + return ( + +
+ {item.label ?? item.id} +
+
+ ); +} + +function usesTemplateFrame(templateProfile?: TemplateProfile): templateProfile is NonNullable { + return Boolean( + templateProfile && + (templateProfile.layoutPreset === 'cinematic_matte' || + templateProfile.layoutPreset === 'letterbox_frame' || + templateProfile.motionLanguage.hasMaskReveals || + templateProfile.motionLanguage.hasViewportSlides), + ); +} + +function usesViewportFrame(item: RemotionTimelineItem): boolean { + return ( + item.framePolicy === 'landscape_viewport' || + item.framePolicy === 'reference_viewport' || + item.framePolicy === 'blurred_pad' + ); +} + +function cloneMediaChild(children: React.ReactNode, style: React.CSSProperties): React.ReactNode { + if (!React.isValidElement<{ style?: React.CSSProperties }>(children)) return children; + const childStyle = children.props.style ?? {}; + return React.cloneElement(children, { + style: { + ...childStyle, + ...style, + ...(childStyle.opacity === undefined ? {} : { opacity: childStyle.opacity }), + }, + }); +} + +function viewportBackgroundStyle(item: RemotionTimelineItem): React.CSSProperties { + return { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: cropObjectPosition(item), + filter: 'blur(30px) saturate(1.05) brightness(0.52)', + transform: 'scale(1.16)', + }; +} + +function viewportForegroundStyle(item: RemotionTimelineItem): React.CSSProperties { + const isReference = item.framePolicy === 'reference_viewport'; + return { + width: '100%', + height: '100%', + objectFit: 'contain', + objectPosition: '50% 50%', + filter: isReference ? 'saturate(1.06) contrast(1.02)' : 'none', + transform: 'translateZ(0)', + }; +} + +function viewportFrameStyle( + item: RemotionTimelineItem, + canvasWidth: number, + canvasHeight: number, +): React.CSSProperties { + const isReference = item.framePolicy === 'reference_viewport'; + if (isReference) { + const softMask = + 'radial-gradient(ellipse 88% 64% at 50% 50%, #000 0%, #000 58%, rgba(0,0,0,0.82) 74%, rgba(0,0,0,0.28) 90%, transparent 100%)'; + return { + position: 'absolute', + inset: 0, + width: canvasWidth, + height: canvasHeight, + overflow: 'hidden', + borderRadius: 0, + background: 'transparent', + WebkitMaskImage: softMask, + maskImage: softMask, + }; + } + const width = Math.round(canvasWidth * 0.9); + const height = Math.round(Math.min(canvasHeight * 0.4, width * 9 / 16)); + return { + position: 'absolute', + left: Math.round((canvasWidth - width) / 2), + top: Math.round(canvasHeight * 0.29), + width, + height, + overflow: 'hidden', + borderRadius: 12, + background: '#05070b', + boxShadow: [ + '0 22px 70px rgba(0,0,0,0.42)', + 'inset 0 0 0 1px rgba(255,255,255,0.12)', + '0 0 0 1px rgba(255,255,255,0.06)', + ].join(', '), + }; +} + +function referenceBlendOverlayStyle(): React.CSSProperties { + return { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + background: + 'linear-gradient(180deg, rgba(3,7,18,0.26) 0%, rgba(3,7,18,0.02) 28%, rgba(3,7,18,0.02) 68%, rgba(3,7,18,0.3) 100%)', + boxShadow: 'inset 0 0 150px rgba(3,7,18,0.34)', + }; +} + +function templateViewportStyle( + templateProfile: NonNullable, + canvasWidth: number, + canvasHeight: number, + progress: number, +): React.CSSProperties { + const box = templateViewportBox(templateProfile, canvasWidth, canvasHeight); + const slide = templateProfile.motionLanguage.hasViewportSlides + ? interpolate(progress, [0, 0.18, 1], [28, 0, -10]) + : 0; + const scale = templateProfile.motionLanguage.internalMotionIntensity === 'high' + ? interpolate(progress, [0, 0.16, 1], [0.985, 1.012, 1]) + : 1; + return { + position: 'absolute', + left: box.left, + top: box.top, + width: box.width, + height: box.height, + overflow: 'hidden', + borderRadius: templateProfile.frameStyle.roundedMask ? 18 : 2, + border: templateProfile.frameStyle.borderColor ? `1px solid ${templateProfile.frameStyle.borderColor}` : undefined, + boxShadow: '0 26px 90px rgba(0,0,0,0.55)', + background: '#111827', + transform: `translateX(${slide}px) scale(${scale})`, + transformOrigin: '50% 50%', + }; +} + +function templateViewportBox( + templateProfile: NonNullable, + canvasWidth: number, + canvasHeight: number, +) { + if (isCarouselTemplateProfile(templateProfile)) { + return cameraCarouselViewportBox(templateProfile, canvasWidth, canvasHeight); + } + const viewport = templateProfile.frameStyle.viewport; + const aspect = aspectNumber(viewport?.aspectRatio ?? templateProfile.sourceAspect); + const width = Math.round(canvasWidth * (templateProfile.layoutPreset === 'cinematic_matte' ? 0.9 : 0.92)); + const maxHeight = Math.round(canvasHeight * 0.48); + const height = Math.round(Math.min(maxHeight, width / aspect)); + const left = Math.round((canvasWidth - width) / 2); + const top = Math.round(canvasHeight * (templateProfile.layoutPreset === 'cinematic_matte' ? 0.42 : 0.43) - height / 2); + return { left, top, width, height }; +} + +function cameraCarouselFrameBox( + templateProfile: NonNullable, + canvasWidth: number, + canvasHeight: number, +) { + const aspect = aspectNumber(templateProfile.sourceAspect); + const width = Math.round(Math.min(canvasWidth * 0.96, canvasHeight * 0.5 * aspect)); + const height = Math.round(width / aspect); + const left = Math.round((canvasWidth - width) / 2); + const top = Math.round(canvasHeight * 0.44 - height / 2); + return { left, top, width, height }; +} + +function cameraCarouselViewportBox( + templateProfile: NonNullable, + canvasWidth: number, + canvasHeight: number, +) { + const frame = cameraCarouselFrameBox(templateProfile, canvasWidth, canvasHeight); + const viewport = templateProfile.frameStyle.viewport ?? { + x: 0.08, + y: 0.24, + width: 0.84, + height: 0.62, + }; + return { + left: Math.round(frame.left + frame.width * viewport.x), + top: Math.round(frame.top + frame.height * viewport.y), + width: Math.round(frame.width * viewport.width), + height: Math.round(frame.height * viewport.height), + }; +} + +function aspectNumber(aspect: string): number { + const [w, h] = aspect.split(':').map((part) => Number(part)); + if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return w / h; + return 16 / 9; +} + +function Subtitle({ text }: { text: string }) { + return ( + +
+ {text} +
+
+ ); +} + +function RealBackgroundTextOverlay({ + item, + progress, + canvasWidth, +}: { + item: RemotionTimelineItem; + progress: number; + canvasWidth: number; +}) { + const lines = wrapLines(item.overlayText ?? '').slice(0, 4); + const box = overlayTextBoxStyle(item, canvasWidth); + return ( + +
+
+
+
+ {lines.map((line, idx) => ( +
{line}
+ ))} +
+
+ + ); +} + +function overlayAlign(item: RemotionTimelineItem): React.CSSProperties['alignItems'] { + if (item.cardStylePreset === 'cover_card' || item.cardStylePreset === 'social_punch') return 'center'; + return 'flex-start'; +} + +function overlayScrimStyle(item: RemotionTimelineItem): React.CSSProperties { + const light = + item.cardStylePreset === 'clean_product' || + item.cardStylePreset === 'lifestyle_story' || + item.cardStylePreset === 'editorial_caption'; + return { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: 620, + background: light + ? 'linear-gradient(180deg, transparent 0%, rgba(12,18,28,0.12) 32%, rgba(12,18,28,0.36) 100%)' + : 'linear-gradient(180deg, transparent 0%, rgba(2,6,23,0.14) 26%, rgba(2,6,23,0.58) 100%)', + }; +} + +function overlayTextBoxStyle(item: RemotionTimelineItem, canvasWidth: number): React.CSSProperties { + const maxWidth = Math.min(850, canvasWidth - 148); + const common: React.CSSProperties = { + position: 'relative', + maxWidth, + boxSizing: 'border-box', + whiteSpace: 'pre-line', + letterSpacing: 0, + lineHeight: 1.14, + textShadow: '0 4px 24px rgba(0,0,0,0.48)', + color: '#fff7ed', + fontSize: 58, + fontWeight: 860, + textAlign: 'left', + }; + if (item.cardStylePreset === 'social_punch') { + return { + ...common, + padding: '28px 34px 30px', + background: 'rgba(8,13,22,0.74)', + borderLeft: '14px solid #facc15', + boxShadow: '0 24px 70px rgba(0,0,0,0.34)', + color: '#f8fafc', + fontSize: 62, + fontWeight: 930, + textAlign: 'center', + }; + } + if (item.cardStylePreset === 'clean_product') { + return { + ...common, + padding: '24px 30px 26px', + background: 'rgba(255,255,255,0.78)', + borderLeft: '8px solid rgba(15,23,42,0.28)', + boxShadow: '0 18px 54px rgba(15,23,42,0.2)', + color: '#111827', + textShadow: 'none', + fontSize: 54, + fontWeight: 780, + }; + } + if (item.cardStylePreset === 'lifestyle_story') { + return { + ...common, + padding: '22px 30px 24px', + background: 'rgba(255,251,235,0.76)', + borderLeft: '8px solid #a8a29e', + boxShadow: '0 16px 48px rgba(28,25,23,0.16)', + color: '#1c1917', + textShadow: 'none', + fontSize: 52, + fontWeight: 740, + }; + } + if (item.cardStylePreset === 'title_bar') { + return { + ...common, + padding: '20px 28px 22px', + background: '#f59e0b', + color: '#111827', + textShadow: 'none', + boxShadow: '0 18px 54px rgba(0,0,0,0.26)', + fontSize: 54, + fontWeight: 900, + }; + } + if (item.cardStylePreset === 'editorial_caption') { + return { + ...common, + padding: '0 0 0 24px', + borderLeft: '5px solid rgba(255,255,255,0.78)', + color: '#f8fafc', + fontSize: 48, + fontWeight: 650, + lineHeight: 1.2, + }; + } + return { + ...common, + padding: '0 0 0 24px', + borderLeft: '7px solid rgba(251,191,36,0.82)', + }; +} + +function overlayAccentStyle(item: RemotionTimelineItem): React.CSSProperties { + if (item.cardStylePreset !== 'cover_card') { + return { display: 'none' }; + } + return { + width: 108, + height: 8, + margin: '0 auto 22px', + background: '#ef4444', + }; +} + +function overlayMotionStyle( + item: RemotionTimelineItem, + progress: number, +): React.CSSProperties { + const entry = interpolate(progress, [0, 0.22], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + if (item.cardAnimationPreset === 'slide_left') { + return { + opacity: entry, + transform: `translateX(${interpolate(entry, [0, 1], [-72, 0])}px)`, + }; + } + if (item.cardAnimationPreset === 'wipe_up') { + return { + opacity: entry, + transform: `translateY(${interpolate(entry, [0, 1], [54, 0])}px)`, + clipPath: `inset(${interpolate(entry, [0, 1], [100, 0])}% 0 0 0)`, + }; + } + if (item.cardAnimationPreset === 'snap_pop') { + return { + opacity: entry, + transform: `scale(${interpolate(entry, [0, 0.72, 1], [0.92, 1.04, 1])})`, + }; + } + return { + opacity: entry, + transform: `translateY(${interpolate(entry, [0, 1], [34, 0])}px)`, + }; +} + +function animatedVisualStyle( + item: RemotionTimelineItem, + progress: number, + frame: number, + entryFrames: number, + exitFrames: number, +): React.CSSProperties { + const base = baseMotionStyle(item, progress); + const transition = transitionStyle(item, frame, entryFrames, exitFrames); + return { + ...base, + opacity: transition.opacity, + filter: transition.filter, + clipPath: transition.clipPath, + transform: `${base.transform} ${transition.transform}`.trim(), + }; +} + +function baseMotionStyle(item: RemotionTimelineItem, progress: number): React.CSSProperties { + const contain = item.cropPreset === 'contain'; + const cropScale = item.cropPreset === 'closeup' ? 1.08 : 1; + const scale = contain + ? 1 + : item.motionPreset === 'push_out' + ? interpolate(progress, [0, 1], [1.12, 1]) * cropScale + : item.motionPreset === 'push_in' || item.motionPreset === 'ken_burns_in' + ? interpolate(progress, [0, 1], [1, 1.1]) * cropScale + : item.motionPreset === 'snap_zoom' + ? interpolate(progress, [0, 0.2, 1], [1, 1.16, 1.08]) * cropScale + : item.motionPreset === 'beat_pulse' + ? interpolate(progress, [0, 0.16, 0.42, 1], [1, 1.13, 1.06, 1.1]) * cropScale + : item.motionPreset === 'tilt_in' + ? interpolate(progress, [0, 1], [1.04, 1.13]) * cropScale + : item.motionPreset === 'parallax_drift' || item.motionPreset === 'reveal_pan' + ? interpolate(progress, [0, 1], [1.08, 1.14]) * cropScale + : cropScale; + const x = contain + ? 0 + : item.motionPreset === 'pan_left' + ? interpolate(progress, [0, 1], [36, -36]) + : item.motionPreset === 'pan_right' + ? interpolate(progress, [0, 1], [-36, 36]) + : item.motionPreset === 'parallax_drift' + ? interpolate(progress, [0, 0.5, 1], [-28, 10, 34]) + : item.motionPreset === 'reveal_pan' + ? interpolate(progress, [0, 1], [84, -24]) + : 0; + const y = contain + ? 0 + : item.motionPreset === 'pan_up' + ? interpolate(progress, [0, 1], [36, -36]) + : item.motionPreset === 'pan_down' + ? interpolate(progress, [0, 1], [-36, 36]) + : item.motionPreset === 'parallax_drift' + ? interpolate(progress, [0, 1], [22, -18]) + : item.motionPreset === 'tilt_in' + ? interpolate(progress, [0, 1], [34, -8]) + : 0; + const rotate = contain + ? 0 + : item.motionPreset === 'tilt_in' + ? interpolate(progress, [0, 1], [-1.2, 0.4]) + : item.motionPreset === 'beat_pulse' + ? interpolate(progress, [0, 0.16, 1], [0, -0.7, 0]) + : 0; + + return { + width: '100%', + height: '100%', + objectFit: contain ? 'contain' : 'cover', + objectPosition: cropObjectPosition(item), + transform: `translate(${x}px, ${y}px) rotate(${rotate}deg) scale(${scale})`, + }; +} + +function transitionStyle( + item: RemotionTimelineItem, + frame: number, + entryFrames: number, + exitFrames: number, +): { + opacity: number; + filter: string; + clipPath?: string; + transform: string; +} { + const entry = entryFrames > 0 ? Math.max(0, Math.min(1, frame / entryFrames)) : 1; + const exitStart = entryFrames + item.durationInFrames; + const exit = exitFrames > 0 ? Math.max(0, Math.min(1, (frame - exitStart) / exitFrames)) : 0; + const visible = Math.max(0, Math.min(1, entry)) * (1 - exit * 0.85); + const preset = item.sourceKind === 'text_card' ? (item.cardAnimationPreset ?? 'fade_push') : (item.transitionPreset ?? 'cut'); + + if (preset === 'slide_left') { + return { + opacity: visible, + filter: 'none', + transform: `translateX(${interpolate(entry, [0, 1], [180, 0])}px)`, + }; + } + if (preset === 'snap_pop' || preset === 'snap_cut') { + const pop = interpolate(entry, [0, 0.72, 1], [0.88, 1.07, 1]); + return { opacity: visible, filter: 'none', transform: `scale(${pop})` }; + } + if (preset === 'wipe_up') { + const inset = interpolate(entry, [0, 1], [100, 0]); + return { + opacity: 1 - exit * 0.85, + filter: 'none', + clipPath: `inset(${inset}% 0 0 0)`, + transform: 'translateY(0)', + }; + } + if (preset === 'whip_cut') { + return { + opacity: visible, + filter: `blur(${interpolate(entry, [0, 1], [10, 0])}px)`, + transform: `translateX(${interpolate(entry, [0, 1], [150, 0])}px)`, + }; + } + if (preset === 'fade_push') { + return { + opacity: visible, + filter: 'none', + transform: `translateY(${interpolate(entry, [0, 1], [48, 0])}px) scale(${interpolate(entry, [0, 1], [0.98, 1])})`, + }; + } + return { + opacity: visible, + filter: 'none', + transform: 'translateZ(0)', + }; +} + +function stageStyle(item: RemotionTimelineItem): React.CSSProperties { + return { + backgroundColor: '#0f172a', + overflow: 'hidden', + opacity: item.track === 'overlay' ? 0.92 : 1, + }; +} + +function cropObjectPosition(item: RemotionTimelineItem): React.CSSProperties['objectPosition'] { + switch (item.cropPreset) { + case 'top': + return '50% 18%'; + case 'bottom': + return '50% 82%'; + case 'left': + return '18% 50%'; + case 'right': + return '82% 50%'; + case 'closeup': + return '50% 45%'; + default: + return '50% 50%'; + } +} + +function cardBackground(item: RemotionTimelineItem): React.CSSProperties { + if (item.cardStylePreset === 'social_punch') { + return { background: 'linear-gradient(180deg, #0b0f19 0%, #171717 48%, #fffbeb 100%)' }; + } + if (item.cardStylePreset === 'clean_product') { + return { background: '#f8fafc', color: '#111827' }; + } + if (item.cardStylePreset === 'lifestyle_story') { + return { background: 'linear-gradient(180deg, #fafaf9 0%, #e7e5e4 100%)', color: '#1c1917' }; + } + if (item.cardStylePreset === 'title_bar') { + return { background: 'linear-gradient(180deg, #0f172a 0%, #111827 58%, #020617 100%)' }; + } + if (item.cardStylePreset === 'cover_card') { + return { background: 'linear-gradient(160deg, #0f172a 0%, #155e75 58%, #f59e0b 100%)' }; + } + if (item.cardStylePreset === 'sticker_pop') { + return { background: '#f8fafc', color: '#0f172a' }; + } + if (item.cardStylePreset === 'editorial_caption') { + return { background: '#e5e7eb', color: '#111827' }; + } + return { background: '#111827' }; +} + +function CardDecor({ item }: { item: RemotionTimelineItem }) { + if (item.cardStylePreset === 'title_bar') { + return ( + <> +
+
+ VISIONFORGE +
+ + ); + } + if (item.cardStylePreset === 'sticker_pop') { + return ( +
+ ); + } + if (item.cardStylePreset === 'editorial_caption') { + return ( +
+ ); + } + if (item.cardStylePreset === 'social_punch') { + return ( + <> +
+
+ + ); + } + if (item.cardStylePreset === 'clean_product') { + return ( +
+ ); + } + if (item.cardStylePreset === 'lifestyle_story') { + return ( +
+ ); + } + return null; +} + +function cardAccent(item: RemotionTimelineItem): { text: string; style: React.CSSProperties } | null { + if (item.cardStylePreset === 'social_punch') { + return null; + } + if (item.cardStylePreset === 'clean_product') { + return null; + } + if (item.cardStylePreset === 'cover_card') { + return null; + } + if (item.cardStylePreset === 'editorial_caption') { + return null; + } + return null; +} + +function textCardShell(item: RemotionTimelineItem): React.CSSProperties { + const isLight = + item.cardStylePreset === 'sticker_pop' || + item.cardStylePreset === 'editorial_caption' || + item.cardStylePreset === 'clean_product' || + item.cardStylePreset === 'lifestyle_story'; + return { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 16, + padding: 96, + boxSizing: 'border-box', + color: item.cardStylePreset === 'social_punch' ? '#f8fafc' : isLight ? '#111827' : '#f8fafc', + fontSize: item.cardStylePreset === 'cover_card' || item.cardStylePreset === 'social_punch' ? 68 : 56, + fontWeight: 900, + lineHeight: 1.12, + textAlign: 'center', + }; +} + +function wrapLines(text: string): string[] { + const lines = text.split('\n').flatMap((line) => { + const chunks: string[] = []; + for (let i = 0; i < line.length; i += 12) chunks.push(line.slice(i, i + 12)); + return chunks.length ? chunks : ['']; + }); + return lines.slice(0, 6); +} diff --git a/apps/api/src/render/remotion/__tests__/prepare.test.ts b/apps/api/src/render/remotion/__tests__/prepare.test.ts new file mode 100644 index 0000000..7cf5343 --- /dev/null +++ b/apps/api/src/render/remotion/__tests__/prepare.test.ts @@ -0,0 +1,177 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { Timeline } from '../../../core/timeline'; +import { entryTransitionTiming, transitionLabel } from '../animation'; +import { prepareRemotionTimeline, preprocessedVideoFilter } from '../prepare'; + +describe('remotion timeline preparation', () => { + it('converts timeline seconds, assets, text cards, subtitles and presets into Remotion props', () => { + const work = mkdtempSync(join(tmpdir(), 'vf-remotion-prepare-')); + try { + const asset = join(work, 'shot.png'); + writeFileSync(asset, 'not-a-real-image-but-copyable'); + const timeline: Timeline = { + id: 'tl', + projectId: 'p1', + durationSec: 6, + items: [ + { + id: 'shot_a', + track: 'video', + startSec: 0, + endSec: 3, + motionPreset: 'push_in', + transitionPreset: 'crossfade', + cropPreset: 'closeup', + framePolicy: 'real_background_lower_third', + overlayText: '真实画面上的标题', + shotRef: 'director_shot_1', + source: { kind: 'user_asset', assetId: 'asset_a' }, + }, + { + id: 'card_b', + track: 'text', + startSec: 3, + endSec: 6, + cardStylePreset: 'title_bar', + cardAnimationPreset: 'slide_left', + source: { kind: 'fill_artifact', fillArtifactId: 'fill_b' }, + }, + ], + }; + + const prepared = prepareRemotionTimeline(timeline, { + publicDir: join(work, 'public'), + fps: 30, + resolveAsset: (source) => { + if (source.kind === 'user_asset') return asset; + if (source.kind === 'fill_artifact') return `textcard://${encodeURIComponent('核心卖点')}`; + return null; + }, + subtitles: [{ startSec: 1, endSec: 2, text: '字幕' }], + }); + + expect(prepared.usedDummy).toBe(false); + expect(prepared.props.durationInFrames).toBe(180); + expect(prepared.props.items[0]).toMatchObject({ + id: 'shot_a', + startFrame: 0, + durationInFrames: 90, + sourceKind: 'image', + motionPreset: 'push_in', + cropPreset: 'closeup', + framePolicy: 'real_background_lower_third', + overlayText: '真实画面上的标题', + shotRef: 'director_shot_1', + }); + expect(prepared.props.items[0].staticPath).toMatch(/^assets\//); + expect(existsSync(join(work, 'public', prepared.props.items[0].staticPath!))).toBe(true); + expect(prepared.props.items[1]).toMatchObject({ + sourceKind: 'text_card', + text: '核心卖点', + cardStylePreset: 'title_bar', + cardAnimationPreset: 'slide_left', + }); + expect(prepared.props.subtitles[0]).toMatchObject({ + startFrame: 30, + durationInFrames: 30, + text: '字幕', + }); + } finally { + rmSync(work, { recursive: true, force: true }); + } + }); + + it('maps director transition and card animation presets to frame timing', () => { + expect(transitionLabel(entryTransitionTiming({ + id: 'shot', + track: 'video', + startFrame: 0, + durationInFrames: 60, + startSec: 0, + endSec: 2, + sourceKind: 'video', + transitionPreset: 'whip_cut', + }, 30))).toBe('whip_cut:5f'); + + expect(transitionLabel(entryTransitionTiming({ + id: 'card', + track: 'text', + startFrame: 60, + durationInFrames: 60, + startSec: 2, + endSec: 4, + sourceKind: 'text_card', + cardAnimationPreset: 'wipe_up', + }, 30))).toBe('wipe_up:9f'); + }); + + it('keeps contain video preprocessing letterboxed instead of cropped', () => { + const containFilter = preprocessedVideoFilter(1080, 1920, 30, true, true); + expect(containFilter).toContain('force_original_aspect_ratio=decrease'); + expect(containFilter).toContain('pad=1080:1920'); + expect(containFilter).not.toContain('crop='); + expect(containFilter).not.toContain('deshake'); + + const coverFilter = preprocessedVideoFilter(1080, 1920, 30, false); + expect(coverFilter).toContain('force_original_aspect_ratio=increase'); + expect(coverFilter).toContain('crop=1080:1920'); + }); + + it('passes viewport frame policies through without video prebaking', () => { + const work = mkdtempSync(join(tmpdir(), 'vf-remotion-viewport-')); + try { + const asset = join(work, 'wide.mp4'); + writeFileSync(asset, 'fake-video-copy-only'); + const timeline: Timeline = { + id: 'tl_viewport', + projectId: 'p1', + durationSec: 3, + items: [ + { + id: 'wide_scene', + track: 'video', + startSec: 0, + endSec: 3, + sourceInSec: 1, + cropPreset: 'contain', + framePolicy: 'landscape_viewport', + source: { kind: 'user_asset', assetId: 'wide_asset' }, + }, + { + id: 'reference_bridge', + track: 'video', + startSec: 0, + endSec: 3, + sourceInSec: 1.5, + cropPreset: 'contain', + framePolicy: 'reference_viewport', + source: { kind: 'raw', path: asset }, + }, + ], + }; + + const prepared = prepareRemotionTimeline(timeline, { + publicDir: join(work, 'public'), + fps: 30, + resolveAsset: () => asset, + }); + + expect(prepared.props.items[0]).toMatchObject({ + sourceKind: 'video', + cropPreset: 'contain', + framePolicy: 'landscape_viewport', + }); + expect(prepared.props.items[1]).toMatchObject({ + sourceKind: 'video', + cropPreset: 'contain', + framePolicy: 'reference_viewport', + }); + expect(prepared.warnings).not.toContain('视频预烘焙失败,已使用原始素材。'); + } finally { + rmSync(work, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/api/src/render/remotion/animation.ts b/apps/api/src/render/remotion/animation.ts new file mode 100644 index 0000000..2049db3 --- /dev/null +++ b/apps/api/src/render/remotion/animation.ts @@ -0,0 +1,39 @@ +import type { CardAnimationPreset, TransitionPreset } from '../../core/timeline'; +import type { RemotionTimelineItem } from './types'; + +export interface RemotionTransitionTiming { + frames: number; + preset: TransitionPreset | CardAnimationPreset | 'cut'; +} + +export function entryTransitionTiming(item: RemotionTimelineItem, fps: number): RemotionTransitionTiming { + if (item.sourceKind === 'text_card') { + return cardAnimationTiming(item.cardAnimationPreset ?? 'fade_push', fps); + } + return transitionTiming(item.transitionPreset ?? 'cut', fps); +} + +export function transitionTiming(preset: TransitionPreset, fps: number): RemotionTransitionTiming { + const sec: Record = { + cut: 0, + crossfade: 0.24, + whip_cut: 0.16, + snap_cut: 0.1, + }; + return { preset, frames: Math.max(0, Math.round(sec[preset] * fps)) }; +} + +export function cardAnimationTiming(preset: CardAnimationPreset, fps: number): RemotionTransitionTiming { + const sec: Record = { + fade_push: 0.28, + slide_left: 0.32, + snap_pop: 0.16, + wipe_up: 0.3, + soft_crossfade: 0.34, + }; + return { preset, frames: Math.max(1, Math.round(sec[preset] * fps)) }; +} + +export function transitionLabel(timing: RemotionTransitionTiming): string { + return timing.preset === 'cut' || timing.frames === 0 ? 'cut' : `${timing.preset}:${timing.frames}f`; +} diff --git a/apps/api/src/render/remotion/prepare.ts b/apps/api/src/render/remotion/prepare.ts new file mode 100644 index 0000000..e072e5e --- /dev/null +++ b/apps/api/src/render/remotion/prepare.ts @@ -0,0 +1,339 @@ +import { spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { basename, extname, join } from 'node:path'; +import type { Timeline, TimelineItem, TimelineSource } from '../../core/timeline'; +import type { SubtitleCue } from '../ass'; +import { + probeVideoMotionProfile, + selectStableSourceWindow, + stabilizationSkipReason, + type VideoMotionProfile, +} from '../motionProfile'; +import type { RenderOptions } from '../renderTimeline'; +import type { RemotionTimelineItem, RemotionTimelineProps } from './types'; + +export interface PrepareRemotionOptions + extends Pick { + publicDir: string; +} + +export interface PreparedRemotionTimeline { + props: RemotionTimelineProps; + usedDummy: boolean; + warnings: string[]; +} + +const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp']); +const AUDIO_EXT = new Set(['.aac', '.aif', '.aiff', '.flac', '.m4a', '.mp3', '.ogg', '.wav']); +const DEFAULT_BACKGROUND = '#111827'; +const STABILIZED_OVERSCAN = 1.08; + +export function prepareRemotionTimeline( + timeline: Timeline, + opts: PrepareRemotionOptions, +): PreparedRemotionTimeline { + const width = opts.width ?? 1080; + const height = opts.height ?? 1920; + const fps = opts.fps ?? 30; + const durationInFrames = secToFrame(timeline.durationSec, fps, 1); + const assetDir = join(opts.publicDir, 'assets'); + mkdirSync(assetDir, { recursive: true }); + + const warnings: string[] = []; + const pushWarning = (message: string) => { + if (!warnings.includes(message)) warnings.push(message); + }; + + let usedDummy = false; + const items = timeline.items + .slice() + .sort((a, b) => a.startSec - b.startSec) + .map((item, idx): RemotionTimelineItem => { + const startFrame = clampFrame(secToFrame(item.startSec, fps, 0), 0, durationInFrames - 1); + const endFrame = clampFrame(secToFrame(item.endSec, fps, startFrame + 1), startFrame + 1, durationInFrames); + const durationFrames = Math.max(1, endFrame - startFrame); + const base = { + id: item.id, + track: item.track, + startFrame, + durationInFrames: durationFrames, + startSec: item.startSec, + endSec: item.endSec, + motionPreset: item.motionPreset, + transitionPreset: item.transitionPreset, + cropPreset: item.cropPreset, + framePolicy: item.framePolicy, + cardStylePreset: item.cardStylePreset, + cardAnimationPreset: item.cardAnimationPreset, + overlayText: item.overlayText, + slotRef: item.slotRef, + shotRef: item.shotRef, + }; + + const resolved = opts.resolveAsset?.(item.source) ?? null; + if (resolved?.startsWith('textcard://')) { + return { + ...base, + sourceKind: 'text_card', + text: decodeTextCard(resolved), + }; + } + + if (resolved && existsSync(resolved)) { + const sourceKind = inferSourceKind(item, resolved); + const sourceWindow = sourceKind === 'video' + ? selectStableSourceWindow(resolved, { + sourceInSec: item.sourceInSec, + durationSec: item.endSec - item.startSec, + }) + : null; + if (sourceWindow?.adjusted) { + pushWarning(`视频 motion profile:${item.id} 已从 ${formatSec(item.sourceInSec ?? 0)}s 调整到 ${formatSec(sourceWindow.sourceInSec)}s,避开高运动片段。`); + } + const trimBeforeSec = sourceWindow?.sourceInSec ?? item.sourceInSec; + const contain = item.cropPreset === 'contain'; + const viewportPolicy = item.framePolicy === 'landscape_viewport' || item.framePolicy === 'reference_viewport'; + const stabilize = sourceKind === 'video' + ? !contain && !viewportPolicy && shouldStabilizeVideo(resolved, opts.stabilizeVideo, pushWarning, sourceWindow?.profile ?? null) + : false; + const preprocessed = sourceKind === 'video' && !viewportPolicy + ? preprocessedVideoAsset(resolved, assetDir, idx, item, { + width, + height, + fps, + sourceInSec: trimBeforeSec ?? 0, + durationSec: item.endSec - item.startSec, + stabilize, + contain, + pushWarning, + }) + : null; + const assetPath = preprocessed?.path ?? resolved; + return { + ...base, + sourceKind, + trimBeforeFrames: preprocessed ? undefined : trimBeforeSec == null ? undefined : secToFrame(trimBeforeSec, fps, 0), + staticPath: copyPublicAsset(assetPath, assetDir, idx, item), + }; + } + + usedDummy = usedDummy || item.track !== 'audio'; + if (item.track === 'audio') { + pushWarning(`音频素材无法解析,Remotion 输出将不含该音频:${sourceLabel(item.source)}`); + } else { + pushWarning(`素材无法解析,Remotion 已使用动态占位:${dummyLabel(item)}`); + } + return { + ...base, + sourceKind: 'placeholder', + label: dummyLabel(item), + }; + }); + + return { + props: { + width, + height, + fps, + durationInFrames, + backgroundColor: DEFAULT_BACKGROUND, + templateProfile: timeline.templateProfile, + items, + subtitles: subtitleProps(opts.subtitles ?? [], fps, durationInFrames), + }, + usedDummy, + warnings, + }; +} + +function secToFrame(sec: number, fps: number, min: number): number { + return Math.max(min, Math.round(sec * fps)); +} + +function clampFrame(frame: number, min: number, max: number): number { + return Math.max(min, Math.min(max, frame)); +} + +function formatSec(value: number): string { + return value.toFixed(2).replace(/\.?0+$/, ''); +} + +function inferSourceKind(item: TimelineItem, path: string): RemotionTimelineItem['sourceKind'] { + if (item.track === 'audio') return 'audio'; + const ext = extname(path).toLowerCase(); + if (AUDIO_EXT.has(ext)) return 'audio'; + if (IMAGE_EXT.has(ext)) return 'image'; + return 'video'; +} + +function copyPublicAsset(path: string, assetDir: string, idx: number, item: TimelineItem): string { + const ext = extname(path) || sourceFallbackExt(item); + const filename = `${String(idx).padStart(3, '0')}_${sanitize(item.id)}_${sanitize(basename(path, ext))}${ext}`; + const dest = join(assetDir, filename); + if (!existsSync(dest)) copyFileSync(path, dest); + return `assets/${filename}`; +} + +function preprocessedVideoAsset( + path: string, + assetDir: string, + idx: number, + item: TimelineItem, + opts: { + width: number; + height: number; + fps: number; + sourceInSec: number; + durationSec: number; + stabilize: boolean; + contain: boolean; + pushWarning: (message: string) => void; + }, +): { path: string } { + const suffix = opts.contain ? 'contain' : opts.stabilize ? 'stable' : 'baked'; + const dest = join(assetDir, `${String(idx).padStart(3, '0')}_${sanitize(item.id)}_${suffix}.mp4`); + if (existsSync(dest)) return { path: dest }; + + const result = spawnSync( + 'ffmpeg', + [ + '-hide_banner', + '-loglevel', + 'error', + '-y', + ...(opts.sourceInSec > 0 ? ['-ss', String(opts.sourceInSec)] : []), + '-t', + String(Math.max(0.1, opts.durationSec)), + '-i', + path, + '-vf', + preprocessedVideoFilter(opts.width, opts.height, opts.fps, opts.stabilize, opts.contain), + '-an', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-pix_fmt', + 'yuv420p', + dest, + ], + { encoding: 'utf8' }, + ); + + if (result.status === 0 && existsSync(dest)) { + opts.pushWarning( + opts.contain + ? 'Remotion hybrid:参考视频段已先经 FFmpeg 保留完整画面 / fps 统一。' + : opts.stabilize + ? 'Remotion hybrid:真实视频段已先经 FFmpeg 逐 shot 稳定 / 裁切 / fps 统一。' + : 'Remotion hybrid:真实视频段已先经 FFmpeg 逐 shot 裁切 / fps 统一。', + ); + return { path: dest }; + } + + opts.pushWarning('视频预烘焙失败,已使用原始素材。'); + return { path }; +} + +export function preprocessedVideoFilter( + width: number, + height: number, + fps: number, + stabilize: boolean, + contain = false, +): string { + if (contain) { + return [ + `scale=${width}:${height}:force_original_aspect_ratio=decrease`, + `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=0x0f172a`, + `fps=${fps}`, + 'setsar=1', + ].join(','); + } + const overscanWidth = even(Math.ceil(width * STABILIZED_OVERSCAN)); + const overscanHeight = even(Math.ceil(height * STABILIZED_OVERSCAN)); + const steps = stabilize + ? ['deshake=rx=32:ry=32:edge=mirror', `scale=${overscanWidth}:${overscanHeight}:force_original_aspect_ratio=increase`] + : [`scale=${width}:${height}:force_original_aspect_ratio=increase`]; + return [ + ...steps, + `crop=${width}:${height}`, + `fps=${fps}`, + 'setsar=1', + ].join(','); +} + +function even(value: number): number { + return value % 2 === 0 ? value : value + 1; +} + +function shouldStabilizeVideo( + path: string, + mode: RenderOptions['stabilizeVideo'], + pushWarning: (message: string) => void, + motionProfile?: VideoMotionProfile | null, +): boolean { + if (mode === true) { + pushWarning('已对真实视频素材启用 deshake 稳定预处理。'); + return true; + } + if (mode !== 'auto') return false; + + const profile = motionProfile ?? probeVideoMotionProfile(path); + if (!profile) { + pushWarning('视频稳定 auto 探测失败,已跳过 deshake。'); + return false; + } + + const skipReason = stabilizationSkipReason(profile); + if (skipReason === 'dark_high_reflection') { + pushWarning('视频稳定 auto:检测到暗光 / 高反光素材,已跳过 deshake 以避免画面漂移。'); + return false; + } + if (skipReason === 'foreground_motion') { + pushWarning('视频稳定 auto:检测到前景大运动素材,已跳过 deshake 以避免跟踪误判造成漂移。'); + return false; + } + + pushWarning('视频稳定 auto:已对适合的真实视频素材启用 deshake。'); + return true; +} + +function sourceFallbackExt(item: TimelineItem): string { + if (item.track === 'audio') return '.mp3'; + if (item.track === 'text') return '.png'; + return '.mp4'; +} + +function subtitleProps(cues: SubtitleCue[], fps: number, durationInFrames: number) { + return cues.map((cue, idx) => { + const startFrame = clampFrame(secToFrame(cue.startSec, fps, 0), 0, durationInFrames - 1); + const endFrame = clampFrame(secToFrame(cue.endSec, fps, startFrame + 1), startFrame + 1, durationInFrames); + return { + id: `sub_${idx}`, + startFrame, + durationInFrames: Math.max(1, endFrame - startFrame), + text: cue.text, + }; + }); +} + +function decodeTextCard(source: string): string { + return decodeURIComponent(source.slice('textcard://'.length)); +} + +function dummyLabel(item: TimelineItem): string { + if (item.source.kind === 'user_asset') return `asset:${item.source.assetId}`; + if (item.source.kind === 'fill_artifact') return `fill:${item.source.fillArtifactId}`; + return item.source.path; +} + +function sourceLabel(source: TimelineSource): string { + if (source.kind === 'user_asset') return source.assetId; + if (source.kind === 'fill_artifact') return source.fillArtifactId; + return source.path; +} + +function sanitize(input: string): string { + return input.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 48) || 'asset'; +} diff --git a/apps/api/src/render/remotion/renderRemotionTimeline.ts b/apps/api/src/render/remotion/renderRemotionTimeline.ts new file mode 100644 index 0000000..469bd0e --- /dev/null +++ b/apps/api/src/render/remotion/renderRemotionTimeline.ts @@ -0,0 +1,103 @@ +import { bundle } from '@remotion/bundler'; +import { RenderInternals, renderMedia, selectComposition } from '@remotion/renderer'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Timeline } from '../../core/timeline'; +import { ffprobeDuration } from '../ffmpeg'; +import type { RenderOptions, RenderResult } from '../renderTimeline'; +import { prepareRemotionTimeline } from './prepare'; + +const here = dirname(fileURLToPath(import.meta.url)); +const entryPoint = join(here, 'Root.tsx'); +const remotionRoot = resolve(here, '../../..'); +const COMPOSITION_ID = 'VisionForgeTimeline'; +const SYSTEM_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +function remotionPort(): number | null { + const configured = Number(process.env.REMOTION_PORT); + return Number.isInteger(configured) && configured > 0 ? configured : null; +} + +export async function renderRemotionTimeline( + timeline: Timeline, + opts: RenderOptions, +): Promise { + const work = opts.workDir ?? join(tmpdir(), `vf-remotion-${Date.now()}`); + const publicDir = join(work, 'public'); + mkdirSync(publicDir, { recursive: true }); + + try { + const prepared = prepareRemotionTimeline(timeline, { + width: opts.width, + height: opts.height, + fps: opts.fps, + resolveAsset: opts.resolveAsset, + subtitles: opts.subtitles, + stabilizeVideo: opts.stabilizeVideo, + publicDir, + }); + + mkdirSync(resolve(opts.outFile, '..'), { recursive: true }); + const serveUrl = await bundle({ + entryPoint, + publicDir, + onProgress: () => undefined, + ignoreRegisterRootWarning: true, + }); + const browserExecutable = existsSync(SYSTEM_CHROME) ? SYSTEM_CHROME : null; + const port = remotionPort(); + const server = await RenderInternals.prepareServer({ + webpackConfigOrServeUrl: serveUrl, + port, + remotionRoot, + logLevel: 'warn', + indent: false, + offthreadVideoThreads: 2, + offthreadVideoCacheSizeInBytes: null, + binariesDirectory: null, + forceIPv4: true, + sampleRate: 48000, + }); + const serverOption = { server }; + try { + const composition = await selectComposition({ + ...serverOption, + serveUrl, + id: COMPOSITION_ID, + inputProps: prepared.props, + browserExecutable, + port, + logLevel: 'warn', + }); + + await renderMedia({ + ...serverOption, + serveUrl, + composition, + inputProps: prepared.props, + codec: 'h264', + outputLocation: opts.outFile, + overwrite: true, + browserExecutable, + port, + logLevel: 'warn', + x264Preset: 'veryfast', + enforceAudioTrack: true, + }); + } finally { + await server.closeServer(false); + } + + const durationSec = await ffprobeDuration(opts.outFile); + return { + outFile: opts.outFile, + durationSec, + segmentCount: prepared.props.items.filter((item) => item.track !== 'audio').length, + usedDummy: prepared.usedDummy, + warnings: prepared.warnings, + }; + } finally { + if (!opts.workDir) rmSync(work, { recursive: true, force: true }); + } +} diff --git a/apps/api/src/render/remotion/types.ts b/apps/api/src/render/remotion/types.ts new file mode 100644 index 0000000..b9a6936 --- /dev/null +++ b/apps/api/src/render/remotion/types.ts @@ -0,0 +1,53 @@ +import type { + CardAnimationPreset, + CardStylePreset, + CropPreset, + FramePolicy, + MotionPreset, + TransitionPreset, +} from '../../core/timeline'; +import type { TemplateProfile } from '../../core/template'; + +export type RemotionSourceKind = 'video' | 'image' | 'audio' | 'text_card' | 'placeholder'; + +export interface RemotionTimelineItem { + id: string; + track: 'video' | 'text' | 'overlay' | 'audio'; + startFrame: number; + durationInFrames: number; + startSec: number; + endSec: number; + sourceKind: RemotionSourceKind; + staticPath?: string; + text?: string; + label?: string; + trimBeforeFrames?: number; + motionPreset?: MotionPreset; + transitionPreset?: TransitionPreset; + cropPreset?: CropPreset; + framePolicy?: FramePolicy; + cardStylePreset?: CardStylePreset; + cardAnimationPreset?: CardAnimationPreset; + overlayText?: string; + slotRef?: string; + shotRef?: string; +} + +export interface RemotionSubtitleCue { + id: string; + startFrame: number; + durationInFrames: number; + text: string; +} + +export interface RemotionTimelineProps { + [key: string]: unknown; + width: number; + height: number; + fps: number; + durationInFrames: number; + backgroundColor: string; + templateProfile?: TemplateProfile; + items: RemotionTimelineItem[]; + subtitles: RemotionSubtitleCue[]; +} diff --git a/apps/api/src/render/renderTimeline.ts b/apps/api/src/render/renderTimeline.ts index e0333f8..c3260a9 100644 --- a/apps/api/src/render/renderTimeline.ts +++ b/apps/api/src/render/renderTimeline.ts @@ -1,9 +1,26 @@ +import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { extname, join, resolve } from 'node:path'; -import type { Timeline, TimelineItem, TimelineSource } from '../core/timeline'; +import { dirname, extname, join, resolve } from 'node:path'; +import type { + CardAnimationPreset, + CardStylePreset, + MotionPreset, + Timeline, + TimelineItem, + TimelineSource, +} from '../core/timeline'; import { buildAss, type SubtitleCue } from './ass'; -import { ffprobeDuration, runFfmpeg } from './ffmpeg'; +import { ffprobeDuration, ffprobeHasAudio, runFfmpeg } from './ffmpeg'; +import { + probeVideoMotionProfile, + selectStableSourceWindow, + stabilizationSkipReason, + type VideoMotionProfile, + type VideoStabilizationMode, +} from './motionProfile'; + +export type { VideoStabilizationMode } from './motionProfile'; export interface RenderOptions { outFile: string; @@ -22,6 +39,10 @@ export interface RenderOptions { resolveAsset?: (s: TimelineSource) => string | null; /** 要烧录的字幕。 */ subtitles?: SubtitleCue[]; + /** 对真实视频素材做轻量稳定预处理;auto 会跳过暗光 / 高反光液体类素材,避免画面漂移。 */ + stabilizeVideo?: VideoStabilizationMode; + /** 渲染后做响度归一化和首尾淡入淡出;默认由 renderVideo 开启。 */ + normalizeAudio?: boolean; } export interface RenderResult { @@ -30,10 +51,19 @@ export interface RenderResult { segmentCount: number; /** 是否用到了 dummy 占位片段(无真实素材时)。 */ usedDummy: boolean; + /** 渲染时发生的能力降级或素材缺失提示。 */ + warnings: string[]; } const DUMMY_COLORS = ['0x1F2937', '0x374151', '0x4B5563', '0x6B7280', '0x111827']; +interface RenderSegment { + file: string; + durationSec: number; + item: TimelineItem; + isTextCard: boolean; +} + /** 把 Timeline 合成为 MP4。FFmpeg-only;缺真实素材时用占位片段。 */ export async function renderTimeline( timeline: Timeline, @@ -46,37 +76,119 @@ export async function renderTimeline( const work = opts.workDir ?? join(tmpdir(), `vf-render-${Date.now()}`); mkdirSync(work, { recursive: true }); let usedDummy = false; + const warnings: string[] = []; + const pushWarning = (message: string) => { + if (!warnings.includes(message)) warnings.push(message); + }; // 视觉轨 = 除 audio 外按起始时间排序 const visual = timeline.items .filter((i) => i.track !== 'audio') .sort((a, b) => a.startSec - b.startSec); - const segFiles: string[] = []; + const segments: RenderSegment[] = []; for (let idx = 0; idx < visual.length; idx++) { const item = visual[idx]; const dur = Number((item.endSec - item.startSec).toFixed(3)); const seg = join(work, `seg_${idx}.mp4`); const real = opts.resolveAsset?.(item.source) ?? null; + const isTextCard = Boolean(real?.startsWith('textcard://')); - if (real?.startsWith('textcard://')) { - usedDummy = true; - const color = DUMMY_COLORS[idx % DUMMY_COLORS.length]; - await renderTextCard(seg, decodeCardText(real), { W, H, FPS, fontFile, color, dur }); + if (isTextCard && real) { + const style = item.cardStylePreset ?? 'minimal_dark'; + const animation = item.cardAnimationPreset ?? 'fade_push'; + const color = ffmpegColor(cardPalette(style).background) ?? DUMMY_COLORS[idx % DUMMY_COLORS.length]; + const textCard = await renderTextCard(seg, decodeCardText(real), { + W, + H, + FPS, + fontFile, + color, + dur, + style, + animation, + }); + if (textCard.renderer === 'quicklook') { + pushWarning('当前 FFmpeg 缺少 drawtext filter,文字卡已用 macOS QuickLook 渲染。'); + } else if (!textCard.textRendered) { + usedDummy = true; + pushWarning('当前 FFmpeg 缺少 drawtext filter,文字卡已降级为纯色占位。'); + } } else if (real && existsSync(real)) { const isImage = ['.jpg', '.jpeg', '.png', '.webp'].includes(extname(real).toLowerCase()); - await runFfmpeg( - [ - ...(isImage ? ['-loop', '1'] : []), - '-i', real, - '-t', String(dur), - '-vf', `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},fps=${FPS},setsar=1`, - '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', seg, - ], - 'seg-real', - ); + const sourceWindow = isImage + ? null + : selectStableSourceWindow(real, { sourceInSec: item.sourceInSec, durationSec: dur }); + if (sourceWindow?.adjusted) { + pushWarning(`视频 motion profile:${item.id} 已从 ${formatSec(item.sourceInSec ?? 0)}s 调整到 ${formatSec(sourceWindow.sourceInSec)}s,避开高运动片段。`); + } + const effectiveSourceInSec = sourceWindow?.sourceInSec ?? item.sourceInSec; + const inputArgs = isImage + ? ['-loop', '1', '-i', real] + : ['-stream_loop', '-1', ...(effectiveSourceInSec ? ['-ss', String(effectiveSourceInSec)] : []), '-i', real]; + const contain = item.cropPreset === 'contain'; + const stabilize = !isImage && !contain && shouldStabilizeVideo(real, opts.stabilizeVideo, pushWarning, sourceWindow?.profile ?? null); + const baseFilter = visualFilter(item, { W, H, FPS, dur, stabilize }); + const overlayFilter = overlayTextFilter(item, { + work, + idx, + W, + H, + fontFile, + }); + const filter = overlayFilter ? `${baseFilter},${overlayFilter}` : baseFilter; + try { + await runFfmpeg( + [ + ...inputArgs, + '-t', String(dur), + '-vf', filter, + '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', seg, + ], + 'seg-real', + ); + } catch (e) { + if (overlayFilter && isMissingDrawtextFilter(e)) { + pushWarning('当前 FFmpeg 缺少 drawtext filter,实景包装文字 overlay 已跳过。'); + await runFfmpeg( + [ + ...inputArgs, + '-t', String(dur), + '-vf', baseFilter, + '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', seg, + ], + 'seg-real-no-overlay', + ); + continue; + } + if (stabilize) { + pushWarning('视频稳定预处理失败,已降级为普通裁切。'); + await runFfmpeg( + [ + ...inputArgs, + '-t', String(dur), + '-vf', visualFilter(item, { W, H, FPS, dur, stabilize: false }), + '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', seg, + ], + 'seg-real-no-stabilize', + ); + continue; + } + if (item.motionPreset === 'static') throw e; + pushWarning(`运镜 preset ${item.motionPreset} 渲染失败,已降级为静态裁切。`); + await runFfmpeg( + [ + ...inputArgs, + '-t', String(dur), + '-vf', staticVisualFilter({ W, H, FPS, contain }), + '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', seg, + ], + 'seg-real-static-fallback', + ); + } } else { usedDummy = true; + pushWarning(`素材无法解析,已使用纯色占位:${dummyLabel(item)}`); const color = DUMMY_COLORS[idx % DUMMY_COLORS.length]; const drawLabel = opts.drawDummyLabels ? dummyLabel(item) : null; await runFfmpeg( @@ -94,24 +206,48 @@ export async function renderTimeline( 'seg-dummy', ); } - segFiles.push(seg); + segments.push({ file: seg, durationSec: dur, item, isTextCard }); } - // concat(各段编码参数一致,可 copy) - const listFile = join(work, 'list.txt'); - writeFileSync(listFile, `${segFiles.map((f) => `file '${f}'`).join('\n')}\n`); - const concatFile = join(work, 'concat.mp4'); - await runFfmpeg(['-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', concatFile], 'concat'); + const concatFile = await combineSegments(segments, work, pushWarning); // audio:有真实 bgm 用之,否则补静音 const audioItem = timeline.items.find((i) => i.track === 'audio'); const audioReal = audioItem ? (opts.resolveAsset?.(audioItem.source) ?? null) : null; + let useAudio = false; + let loopAudio = false; + if (audioReal && existsSync(audioReal)) { + try { + useAudio = await ffprobeHasAudio(audioReal); + if (!useAudio) { + pushWarning('时间线指定了音频源,但该文件没有可用音频流,已插入静音轨。'); + } else { + const audioDuration = await ffprobeDuration(audioReal); + if (audioDuration + 0.2 < timeline.durationSec) { + loopAudio = true; + pushWarning(`BGM 短于时间线,已循环补足到 ${timeline.durationSec.toFixed(1)}s。`); + } else if (audioDuration > timeline.durationSec + 0.5) { + pushWarning(`BGM 长于时间线,已裁切到 ${timeline.durationSec.toFixed(1)}s。`); + } + } + } catch { + pushWarning('音频源探测失败,已插入静音轨。'); + } + } else if (audioItem) { + pushWarning('时间线指定了音频源,但文件无法解析,已插入静音轨。'); + } else { + pushWarning('时间线没有 audio 轨,已插入静音轨。'); + } // 最终合成:可选烧字幕 + 挂音轨 mkdirSync(resolve(opts.outFile, '..'), { recursive: true }); const finalInputs: string[] = ['-i', concatFile]; - if (audioReal && existsSync(audioReal)) finalInputs.push('-i', audioReal); - else finalInputs.push('-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'); + if (useAudio && audioReal) { + if (loopAudio) finalInputs.push('-stream_loop', '-1'); + finalInputs.push('-i', audioReal); + } else { + finalInputs.push('-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'); + } const outputArgs = ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', opts.outFile]; const noSubtitleArgs = [...finalInputs, '-map', '0:v:0', '-map', '1:a:0', ...outputArgs]; @@ -132,6 +268,7 @@ export async function renderTimeline( await runFfmpeg(subtitleArgs, 'final'); } catch (e) { if (!isMissingSubtitleFilter(e)) throw e; + pushWarning('当前 FFmpeg 缺少 subtitles filter,字幕烧录已跳过。'); await runFfmpeg(noSubtitleArgs, 'final-no-subtitles'); } } else { @@ -140,7 +277,110 @@ export async function renderTimeline( const durationSec = await ffprobeDuration(opts.outFile); if (!opts.workDir) rmSync(work, { recursive: true, force: true }); - return { outFile: opts.outFile, durationSec, segmentCount: segFiles.length, usedDummy }; + return { outFile: opts.outFile, durationSec, segmentCount: segments.length, usedDummy, warnings }; +} + +async function combineSegments( + segments: RenderSegment[], + work: string, + pushWarning: (message: string) => void, +): Promise { + if (segments.length === 1) return segments[0].file; + const shouldAnimateTransitions = segments.some((segment, idx) => { + if (idx === 0) return false; + const prev = segments[idx - 1]; + return prev.isTextCard || segment.isTextCard || segment.item.transitionPreset !== 'cut'; + }); + if (!shouldAnimateTransitions) return concatSegments(segments.map((s) => s.file), work); + + const out = join(work, 'xfade.mp4'); + const inputArgs = segments.flatMap((segment) => ['-i', segment.file]); + const setpts = segments.map((_, idx) => `[${idx}:v]setpts=PTS-STARTPTS[v${idx}]`); + const filters = [...setpts]; + let previousLabel = 'v0'; + let accumulatedDuration = segments[0].durationSec; + + for (let idx = 1; idx < segments.length; idx++) { + const transition = transitionForSegments(segments[idx - 1], segments[idx]); + const duration = Math.min( + transition.durationSec, + Math.max(0.02, segments[idx - 1].durationSec / 2), + Math.max(0.02, segments[idx].durationSec / 2), + ); + const offset = Math.max(0.01, accumulatedDuration - duration); + const nextLabel = `vx${idx}`; + filters.push( + `[${previousLabel}][v${idx}]xfade=transition=${transition.name}:duration=${duration.toFixed(3)}:offset=${offset.toFixed(3)}[${nextLabel}]`, + ); + previousLabel = nextLabel; + accumulatedDuration += segments[idx].durationSec - duration; + } + + try { + await runFfmpeg( + [ + ...inputArgs, + '-filter_complex', + filters.join(';'), + '-map', + `[${previousLabel}]`, + '-an', + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + out, + ], + 'concat-xfade', + ); + const xfadeDuration = await ffprobeDuration(out); + if (xfadeDuration + 0.35 < accumulatedDuration) { + pushWarning( + `转场动画输出时长 ${formatSec(xfadeDuration)}s 短于预期 ${formatSec(accumulatedDuration)}s,已降级为硬切拼接以保留完整时间线。`, + ); + return concatSegments(segments.map((s) => s.file), work); + } + return out; + } catch { + pushWarning('转场动画渲染失败,已降级为硬切拼接。'); + return concatSegments(segments.map((s) => s.file), work); + } +} + +async function concatSegments(segFiles: string[], work: string): Promise { + const listFile = join(work, 'list.txt'); + writeFileSync(listFile, `${segFiles.map((f) => `file '${f}'`).join('\n')}\n`); + const concatFile = join(work, 'concat.mp4'); + await runFfmpeg(['-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', concatFile], 'concat'); + return concatFile; +} + +function transitionForSegments(prev: RenderSegment, next: RenderSegment): { name: string; durationSec: number } { + if (prev.isTextCard || next.isTextCard) { + const animation = (next.isTextCard ? next.item.cardAnimationPreset : prev.item.cardAnimationPreset) ?? 'fade_push'; + return transitionForCardAnimation(animation); + } + switch (next.item.transitionPreset) { + case 'crossfade': + return { name: 'fade', durationSec: 0.24 }; + case 'whip_cut': + return { name: 'hblur', durationSec: 0.16 }; + case 'snap_cut': + return { name: 'fadefast', durationSec: 0.1 }; + default: + return { name: 'fadefast', durationSec: 0.04 }; + } +} + +function transitionForCardAnimation(animation: CardAnimationPreset): { name: string; durationSec: number } { + const map: Record = { + fade_push: { name: 'smoothleft', durationSec: 0.28 }, + slide_left: { name: 'smoothleft', durationSec: 0.32 }, + snap_pop: { name: 'fadefast', durationSec: 0.12 }, + wipe_up: { name: 'smoothup', durationSec: 0.3 }, + soft_crossfade: { name: 'fade', durationSec: 0.34 }, + }; + return map[animation]; } /** dummy 片段上显示的标签(单行,避开 drawtext 特殊字符)。 */ @@ -164,6 +404,116 @@ function decodeCardText(uri: string): string { } } +function staticVisualFilter(o: { W: number; H: number; FPS: number; contain?: boolean }): string { + return o.contain + ? `scale=${o.W}:${o.H}:force_original_aspect_ratio=decrease,pad=${o.W}:${o.H}:(ow-iw)/2:(oh-ih)/2:color=0x0f172a,fps=${o.FPS},setsar=1` + : `scale=${o.W}:${o.H}:force_original_aspect_ratio=increase,crop=${o.W}:${o.H},fps=${o.FPS},setsar=1`; +} + +function formatSec(value: number): string { + return value.toFixed(2).replace(/\.?0+$/, ''); +} + +function visualFilter( + item: TimelineItem, + o: { W: number; H: number; FPS: number; dur: number; stabilize?: boolean }, +): string { + if (item.cropPreset === 'contain') { + return staticVisualFilter({ W: o.W, H: o.H, FPS: o.FPS, contain: true }); + } + const stabilize = o.stabilize ? 'deshake=rx=16:ry=16:edge=mirror,' : ''; + const base = `${stabilize}scale=${o.W}:${o.H}:force_original_aspect_ratio=increase,crop=${o.W}:${o.H}`; + const motion = item.motionPreset ?? 'static'; + if (motion === 'static') return `${base},fps=${o.FPS},setsar=1`; + return `${base},${zoompanFor(motion, o)},setsar=1`; +} + +function overlayTextFilter( + item: TimelineItem, + o: { work: string; idx: number; W: number; H: number; fontFile: string }, +): string | undefined { + const text = item.overlayText?.trim(); + if (!text) return undefined; + const txtFile = join(o.work, `overlay_${o.idx}.txt`); + writeFileSync(txtFile, wrapCjk(text, 12)); + const fontSize = item.cardStylePreset === 'social_punch' ? 62 : item.cardStylePreset === 'editorial_caption' ? 48 : 54; + const boxColor = item.cardStylePreset === 'clean_product' || item.cardStylePreset === 'lifestyle_story' + ? 'white@0.72' + : 'black@0.42'; + const fontColor = item.cardStylePreset === 'clean_product' || item.cardStylePreset === 'lifestyle_story' + ? '0x111827' + : 'white'; + return [ + `drawtext=fontfile='${o.fontFile}'`, + `textfile='${escapeFilterPath(txtFile)}'`, + `fontcolor=${fontColor}`, + `fontsize=${fontSize}`, + 'line_spacing=12', + 'box=1', + `boxcolor=${boxColor}`, + 'boxborderw=24', + 'x=76', + `y=${Math.round(o.H * 0.72)}-text_h/2`, + ].join(':'); +} + +function shouldStabilizeVideo( + path: string, + mode: VideoStabilizationMode | undefined, + pushWarning: (message: string) => void, + motionProfile?: VideoMotionProfile | null, +): boolean { + if (mode === true) { + pushWarning('已对真实视频素材启用 deshake 稳定预处理。'); + return true; + } + if (mode !== 'auto') return false; + + const profile = motionProfile ?? probeVideoMotionProfile(path); + if (!profile) { + pushWarning('视频稳定 auto 探测失败,已跳过 deshake。'); + return false; + } + + const skipReason = stabilizationSkipReason(profile); + if (skipReason === 'dark_high_reflection') { + pushWarning('视频稳定 auto:检测到暗光 / 高反光素材,已跳过 deshake 以避免画面漂移。'); + return false; + } + if (skipReason === 'foreground_motion') { + pushWarning('视频稳定 auto:检测到前景大运动素材,已跳过 deshake 以避免跟踪误判造成漂移。'); + return false; + } + + pushWarning('视频稳定 auto:已对适合的真实视频素材启用 deshake。'); + return true; +} + +function zoompanFor(motion: MotionPreset, o: { W: number; H: number; FPS: number; dur: number }): string { + const frames = Math.max(1, Math.round(o.dur * o.FPS)); + const centerX = "iw/2-(iw/zoom/2)"; + const centerY = "ih/2-(ih/zoom/2)"; + const maxX = 'iw-iw/zoom'; + const maxY = 'ih-ih/zoom'; + const presets: Record = { + static: { z: '1', x: '0', y: '0' }, + ken_burns_in: { z: 'min(zoom+0.0008,1.08)', x: centerX, y: centerY }, + push_in: { z: 'min(zoom+0.0015,1.12)', x: centerX, y: centerY }, + push_out: { z: 'if(eq(on,0),1.12,max(zoom-0.0015,1.0))', x: centerX, y: centerY }, + pan_left: { z: '1.08', x: `${maxX}*(1-on/${frames})`, y: centerY }, + pan_right: { z: '1.08', x: `${maxX}*on/${frames}`, y: centerY }, + pan_up: { z: '1.08', x: centerX, y: `${maxY}*(1-on/${frames})` }, + pan_down: { z: '1.08', x: centerX, y: `${maxY}*on/${frames}` }, + snap_zoom: { z: 'min(zoom+0.004,1.16)', x: centerX, y: `${maxY}/2` }, + parallax_drift: { z: '1.10+0.03*on/' + frames, x: `${maxX}*(0.2+0.45*on/${frames})`, y: `${maxY}*(0.35-0.18*on/${frames})` }, + tilt_in: { z: 'min(zoom+0.0018,1.14)', x: `${maxX}*(0.45+0.08*on/${frames})`, y: `${maxY}*(0.55-0.18*on/${frames})` }, + beat_pulse: { z: `if(lt(on,${Math.max(2, Math.round(frames * 0.16))}),1+0.16*on/${Math.max(2, Math.round(frames * 0.16))},1.08)`, x: centerX, y: centerY }, + reveal_pan: { z: '1.12', x: `${maxX}*(0.85-0.6*on/${frames})`, y: centerY }, + }; + const preset = presets[motion]; + return `zoompan=z='${preset.z}':d=1:x='${preset.x}':y='${preset.y}':s=${o.W}x${o.H}:fps=${o.FPS}`; +} + /** 按每行字数折行(CJK 友好),用于文字卡居中排版。 */ function wrapCjk(text: string, perLine: number): string { return text @@ -180,8 +530,17 @@ function wrapCjk(text: string, perLine: number): string { async function renderTextCard( seg: string, text: string, - o: { W: number; H: number; FPS: number; fontFile: string; color: string; dur: number }, -): Promise { + o: { + W: number; + H: number; + FPS: number; + fontFile: string; + color: string; + dur: number; + style: CardStylePreset; + animation: CardAnimationPreset; + }, +): Promise<{ textRendered: boolean; renderer: 'drawtext' | 'quicklook' | 'plain' }> { const txtFile = `${seg}.txt`; writeFileSync(txtFile, wrapCjk(text, 12)); const base = ['-f', 'lavfi', '-i', `color=c=${o.color}:s=${o.W}x${o.H}:d=${o.dur}:r=${o.FPS}`]; @@ -194,10 +553,385 @@ async function renderTextCard( ]; try { await runFfmpeg(withText, 'seg-textcard'); + return { textRendered: true, renderer: 'drawtext' }; } catch (e) { if (!isMissingDrawtextFilter(e)) throw e; + if (await renderTextCardWithQuickLook(seg, text, o)) { + return { textRendered: true, renderer: 'quicklook' }; + } await runFfmpeg([...base, ...tail], 'seg-textcard-plain'); + return { textRendered: false, renderer: 'plain' }; + } +} + +async function renderTextCardWithQuickLook( + seg: string, + text: string, + o: { + W: number; + H: number; + FPS: number; + color: string; + dur: number; + style: CardStylePreset; + animation: CardAnimationPreset; + }, +): Promise { + const htmlFile = `${seg}.html`; + const pngFile = `${htmlFile}.png`; + writeFileSync(htmlFile, textCardHtml(text, o.style), 'utf8'); + try { + await runProcess('qlmanage', ['-t', '-s', String(Math.min(o.W, o.H)), '-o', dirname(htmlFile), htmlFile]); + if (!existsSync(pngFile)) return false; + await runFfmpeg( + [ + '-loop', + '1', + '-i', + pngFile, + '-t', + String(o.dur), + '-vf', + animatedCardFilter(o), + '-an', + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + seg, + ], + 'seg-textcard-quicklook', + ); + return true; + } catch { + return false; + } +} + +function animatedCardFilter(o: { + W: number; + H: number; + FPS: number; + color: string; + dur: number; + animation: CardAnimationPreset; +}): string { + const fadeOutStart = Math.max(0.08, o.dur - 0.22); + const frames = Math.max(1, Math.round(o.dur * o.FPS)); + const motion = cardMotionExpr(o.animation, frames); + return [ + `scale=${o.W}:${o.H}:force_original_aspect_ratio=decrease`, + `pad=${o.W}:${o.H}:(ow-iw)/2:(oh-ih)/2:color=${o.color}`, + `zoompan=z='${motion.z}':d=1:x='${motion.x}':y='${motion.y}':s=${o.W}x${o.H}:fps=${o.FPS}`, + 'fade=t=in:st=0:d=0.16', + `fade=t=out:st=${fadeOutStart.toFixed(3)}:d=0.22`, + 'setsar=1', + ].join(','); +} + +function cardMotionExpr( + animation: CardAnimationPreset, + frames: number, +): { z: string; x: string; y: string } { + const centerX = 'iw/2-(iw/zoom/2)'; + const centerY = 'ih/2-(ih/zoom/2)'; + const maxX = 'iw-iw/zoom'; + const maxY = 'ih-ih/zoom'; + const map: Record = { + fade_push: { + z: 'min(zoom+0.00025,1.04)', + x: centerX, + y: centerY, + }, + slide_left: { + z: '1.05', + x: `${maxX}*(1-on/${frames})`, + y: centerY, + }, + snap_pop: { + z: 'if(lte(on,6),1.16-on*0.02,1.035)', + x: centerX, + y: centerY, + }, + wipe_up: { + z: '1.05', + x: centerX, + y: `${maxY}*(1-on/${frames})`, + }, + soft_crossfade: { + z: '1.01', + x: centerX, + y: centerY, + }, + }; + return map[animation]; +} + +function textCardHtml(text: string, style: CardStylePreset): string { + const palette = cardPalette(style); + const lines = wrapCjk(text, 12) + .split('\n') + .map((line) => escapeHtml(line)) + .join('
'); + const styleCss = cardStyleCss(style, palette); + return ` + + + + + +
${lines}
+`; +} + +function cardPalette(style: CardStylePreset): { background: string; text: string; accent: string } { + const palettes: Record = { + minimal_dark: { background: '#1F2937', text: '#FFFFFF', accent: '#94A3B8' }, + title_bar: { background: '#101820', text: '#FFFFFF', accent: '#F4C95D' }, + sticker_pop: { background: '#1C2A2E', text: '#101318', accent: '#F2D16B' }, + cover_card: { background: '#09090B', text: '#FFFFFF', accent: '#EF4444' }, + editorial_caption: { background: '#202A36', text: '#F8FAFC', accent: 'rgba(255,255,255,0.2)' }, + social_punch: { background: '#0B0F19', text: '#FFFFFF', accent: '#FACC15' }, + clean_product: { background: '#F8FAFC', text: '#111827', accent: '#CBD5E1' }, + lifestyle_story: { background: '#FAFAF9', text: '#1C1917', accent: '#A8A29E' }, + }; + return palettes[style]; +} + +function ffmpegColor(color: string): string { + return color.startsWith('#') ? `0x${color.slice(1)}` : color; +} + +function cardStyleCss( + style: CardStylePreset, + palette: { background: string; text: string; accent: string }, +): { + alignItems: string; + width: string; + margin: string; + padding: string; + cardBackground: string; + cardText: string; + radius: string; + border: string; + shadow: string; + textAlign: string; + fontSize: string; + fontWeight: number; + transform: string; + accentInset: string; + accentBackground: string; + accentRadius: string; + accentTransform: string; + accentOpacity: number; +} { + const common = { + alignItems: 'center', + width: '820px', + margin: '0', + padding: '0', + cardBackground: 'transparent', + cardText: palette.text, + radius: '0', + border: '0', + shadow: 'none', + textAlign: 'center', + fontSize: '68px', + fontWeight: 720, + transform: 'none', + accentInset: 'auto', + accentBackground: 'transparent', + accentRadius: '0', + accentTransform: 'none', + accentOpacity: 0, + }; + if (style === 'title_bar') { + return { + ...common, + alignItems: 'flex-end', + margin: '0 0 260px', + padding: '36px 46px 42px', + cardBackground: 'rgba(255,255,255,0.08)', + radius: '0', + border: `12px solid ${palette.accent}`, + shadow: '0 32px 90px rgba(0,0,0,0.32)', + fontSize: '58px', + accentInset: '120px 0 auto 0', + accentBackground: palette.accent, + accentRadius: '0', + accentOpacity: 1, + }; + } + if (style === 'sticker_pop') { + return { + ...common, + width: '760px', + padding: '52px 60px', + cardBackground: '#F7F1DF', + cardText: '#111827', + radius: '30px', + border: '8px solid rgba(255,255,255,0.82)', + shadow: '0 34px 90px rgba(0,0,0,0.35)', + fontSize: '62px', + transform: 'rotate(-1.6deg)', + accentInset: '170px 110px auto auto', + accentBackground: palette.accent, + accentRadius: '999px', + accentTransform: 'rotate(8deg)', + accentOpacity: 0.95, + }; + } + if (style === 'cover_card') { + return { + ...common, + width: '860px', + padding: '0', + fontSize: '84px', + fontWeight: 800, + accentInset: '170px auto auto 92px', + accentBackground: palette.accent, + accentRadius: '999px', + accentTransform: 'rotate(-8deg)', + accentOpacity: 0.9, + }; + } + if (style === 'editorial_caption') { + return { + ...common, + alignItems: 'flex-end', + width: '880px', + margin: '0 0 220px', + padding: '28px 34px', + cardBackground: 'rgba(8,13,22,0.68)', + radius: '18px', + fontSize: '46px', + fontWeight: 650, + textAlign: 'left', + accentInset: 'auto 94px 180px 94px', + accentBackground: palette.accent, + accentRadius: '999px', + accentOpacity: 1, + }; + } + if (style === 'social_punch') { + return { + ...common, + width: '820px', + padding: '42px 46px', + cardBackground: 'rgba(15,23,42,0.92)', + radius: '0', + border: `10px solid ${palette.accent}`, + shadow: '0 34px 90px rgba(0,0,0,0.38)', + fontSize: '72px', + fontWeight: 850, + accentInset: '150px 84px auto auto', + accentBackground: '#EF4444', + accentRadius: '0', + accentTransform: 'rotate(-6deg)', + accentOpacity: 0.95, + }; + } + if (style === 'clean_product') { + return { + ...common, + width: '800px', + padding: '36px 48px', + cardBackground: 'rgba(255,255,255,0.82)', + cardText: '#111827', + radius: '10px', + border: '2px solid rgba(15,23,42,0.12)', + shadow: '0 20px 70px rgba(15,23,42,0.16)', + fontSize: '58px', + fontWeight: 760, + accentInset: '170px 96px auto 96px', + accentBackground: palette.accent, + accentRadius: '999px', + accentOpacity: 1, + }; } + if (style === 'lifestyle_story') { + return { + ...common, + alignItems: 'flex-end', + width: '820px', + margin: '0 0 230px', + padding: '28px 36px', + cardBackground: 'rgba(255,255,255,0.76)', + cardText: '#1C1917', + radius: '14px', + shadow: '0 22px 60px rgba(28,25,23,0.14)', + fontSize: '50px', + fontWeight: 700, + textAlign: 'left', + accentInset: 'auto 96px 186px 96px', + accentBackground: palette.accent, + accentRadius: '999px', + accentOpacity: 1, + }; + } + return common; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function runProcess(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const p = spawn(command, args); + let err = ''; + p.stderr.on('data', (d) => { + err += d.toString(); + }); + p.on('error', reject); + p.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`${command} exited ${code}:\n${err}`)), + ); + }); } function isMissingDrawtextFilter(e: unknown): boolean { diff --git a/apps/api/src/render/renderVideo.ts b/apps/api/src/render/renderVideo.ts new file mode 100644 index 0000000..887ce01 --- /dev/null +++ b/apps/api/src/render/renderVideo.ts @@ -0,0 +1,59 @@ +import type { Timeline } from '../core/timeline'; +import { normalizeOutputAudio } from './audioPostprocess'; +import { renderRemotionTimeline } from './remotion/renderRemotionTimeline'; +import { renderTimeline, type RenderOptions, type RenderResult } from './renderTimeline'; + +export type RenderBackend = 'ffmpeg' | 'remotion'; + +export interface RenderVideoOptions extends RenderOptions { + renderer?: RenderBackend; + /** Remotion 失败时是否回退 FFmpeg;默认开启。 */ + fallbackRenderer?: RenderBackend | false; +} + +export interface RenderVideoResult extends RenderResult { + renderer: RenderBackend; + requestedRenderer: RenderBackend; +} + +export async function renderVideo(timeline: Timeline, opts: RenderVideoOptions): Promise { + const requestedRenderer = opts.renderer ?? 'remotion'; + if (requestedRenderer === 'ffmpeg') { + const result = await renderTimeline(timeline, opts); + return { ...(await maybeNormalizeAudio(result, opts)), renderer: 'ffmpeg', requestedRenderer }; + } + + try { + const result = await renderRemotionTimeline(timeline, opts); + return { ...(await maybeNormalizeAudio(result, opts)), renderer: 'remotion', requestedRenderer }; + } catch (e) { + if (opts.fallbackRenderer === false) throw e; + const message = e instanceof Error ? e.message : String(e); + const result = await renderTimeline(timeline, opts); + const normalizedResult = await maybeNormalizeAudio(result, opts); + return { + ...normalizedResult, + renderer: 'ffmpeg', + requestedRenderer, + warnings: [`Remotion 渲染失败,已回退 FFmpeg:${message}`, ...normalizedResult.warnings], + }; + } +} + +async function maybeNormalizeAudio(result: RenderResult, opts: RenderVideoOptions): Promise { + if (opts.normalizeAudio === false) return result; + try { + const normalized = await normalizeOutputAudio(result.outFile, result.durationSec); + return { + ...result, + durationSec: normalized.durationSec, + warnings: [...result.warnings, normalized.warning], + }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + ...result, + warnings: [...result.warnings, `音频归一化失败,保留原始音频:${message}`], + }; + } +} diff --git a/apps/api/src/render/scripts/render-demo-remotion.ts b/apps/api/src/render/scripts/render-demo-remotion.ts new file mode 100644 index 0000000..49f279c --- /dev/null +++ b/apps/api/src/render/scripts/render-demo-remotion.ts @@ -0,0 +1,22 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sampleTimeline } from '../../core/mocks/sample-timeline'; +import { renderVideo } from '../renderVideo'; + +const here = dirname(fileURLToPath(import.meta.url)); +const out = resolve(here, '../../../out/demo-remotion.mp4'); + +const subtitles = [ + { startSec: 0, endSec: 4, text: '你是不是也拍了一堆素材,却剪不出爆款?' }, + { startSec: 4, endSec: 12, text: '问题往往不在素材,而在结构。' }, + { startSec: 12, endSec: 20, text: '把样例的结构蓝图,迁移到你的商品。' }, + { startSec: 20, endSec: 30, text: '现在就试试 VisionForge,限时体验。' }, +]; + +const result = await renderVideo(sampleTimeline, { + renderer: 'remotion', + fallbackRenderer: false, + outFile: out, + subtitles, +}); +console.log('rendered:', result); diff --git a/apps/api/src/scripts/regression-runner.ts b/apps/api/src/scripts/regression-runner.ts new file mode 100644 index 0000000..51b3d9a --- /dev/null +++ b/apps/api/src/scripts/regression-runner.ts @@ -0,0 +1,315 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sampleBlueprint } from '../core/mocks/sample-blueprint'; +import { runRuleBasedMigration, type MigrationPlan } from '../core/migration'; +import type { AssetTag as AssetTagT, MediaType as MediaTypeT } from '../core/enums'; +import type { TaggedAsset } from '../core/slot'; +import type { TimelineSource } from '../core/timeline'; +import { ffprobeDuration, ffprobeHasAudio, runFfmpeg } from '../render/ffmpeg'; +import { renderVideo } from '../render/renderVideo'; + +type RegressionManifest = { + cases: RegressionCaseInput[]; +}; + +type RegressionCaseInput = { + id: string; + topic: string; + sellingPoints?: string[]; + durationSec?: number; + assets: Array<{ + id: string; + path: string; + mediaType: MediaTypeT; + assetTags: AssetTagT[]; + summary: string; + durationSec?: number; + confidence?: number; + hasAudio?: boolean; + isBgm?: boolean; + }>; +}; + +type RegressionReport = { + generatedAt: string; + cases: Array<{ + id: string; + topic: string; + migrationId: string; + outputPath: string; + render: { + renderer?: string; + durationSec: number; + segmentCount: number; + usedDummy: boolean; + warnings: string[]; + }; + audio: { + hasAudio: boolean; + durationSec?: number; + meanVolumeDb?: number; + maxVolumeDb?: number; + headMaxVolumeDb?: number; + tailMaxVolumeDb?: number; + silentRisk: boolean; + headAbruptRisk: boolean; + tailAbruptRisk: boolean; + }; + qc: MigrationPlan['qcReport']; + debug: { + gapCount: number; + fillCount: number; + timelineItemCount: number; + }; + }>; +}; + +const here = dirname(fileURLToPath(import.meta.url)); +const apiRoot = resolve(here, '../..'); +const outDir = resolve(apiRoot, 'out/regression'); + +async function main() { + mkdirSync(outDir, { recursive: true }); + const manifest = process.argv[2] + ? readManifest(resolve(process.argv[2])) + : await buildSmokeManifest(); + const cases = []; + for (const c of manifest.cases) { + cases.push(await runCase(c)); + } + const report: RegressionReport = { + generatedAt: new Date().toISOString(), + cases, + }; + const reportPath = resolve(outDir, 'report.json'); + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`regression report: ${reportPath}`); + for (const c of report.cases) { + console.log(`${c.id}: score=${c.qc.totalScore} verdict=${c.qc.verdict} output=${c.outputPath}`); + } +} + +function readManifest(path: string): RegressionManifest { + const raw = JSON.parse(readFileSync(path, 'utf8')) as RegressionManifest; + return { + cases: raw.cases.map((c) => ({ + ...c, + assets: c.assets.map((asset) => ({ + ...asset, + path: resolve(dirname(path), asset.path), + })), + })), + }; +} + +async function buildSmokeManifest(): Promise { + const fixtureDir = resolve(outDir, 'fixtures'); + mkdirSync(fixtureDir, { recursive: true }); + const video = resolve(fixtureDir, 'product-demo.mp4'); + const bgm = resolve(fixtureDir, 'bgm.m4a'); + if (!existsSync(video)) { + await runFfmpeg([ + '-f', + 'lavfi', + '-i', + 'testsrc2=size=720x1280:rate=30', + '-t', + '5', + '-pix_fmt', + 'yuv420p', + video, + ], 'regression-video-fixture'); + } + if (!existsSync(bgm)) { + await runFfmpeg([ + '-f', + 'lavfi', + '-i', + 'sine=frequency=880:duration=12', + '-c:a', + 'aac', + bgm, + ], 'regression-audio-fixture'); + } + return { + cases: [ + { + id: 'smoke_low_material_with_bgm', + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时', '单手开盖'], + durationSec: 12, + assets: [ + { + id: 'asset_demo_video', + path: video, + mediaType: 'video', + assetTags: ['product_closeup', 'b_roll'], + summary: '合成产品演示视频', + durationSec: 5, + confidence: 0.9, + hasAudio: false, + }, + { + id: 'asset_demo_bgm', + path: bgm, + mediaType: 'audio', + assetTags: [], + summary: '合成测试 BGM', + durationSec: 12, + confidence: 1, + hasAudio: true, + isBgm: true, + }, + ], + }, + ], + }; +} + +async function runCase(input: RegressionCaseInput) { + const assets = input.assets.map((asset) => ({ + id: asset.id, + mediaType: asset.mediaType, + assetTags: asset.assetTags, + durationSec: asset.durationSec, + confidence: asset.confidence ?? 0.85, + summary: asset.summary, + hasAudio: asset.hasAudio, + isBgm: asset.isBgm, + })); + const pathByAsset = new Map(input.assets.map((asset) => [asset.id, asset.path])); + const migration = runRuleBasedMigration({ + projectId: `reg_${input.id}`, + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets, + topic: input.topic, + sellingPoints: input.sellingPoints ?? [], + durationSec: input.durationSec, + }); + const outputPath = resolve(outDir, `${input.id}.mp4`); + const render = await renderVideo(migration.timeline, { + renderer: 'remotion', + fallbackRenderer: 'ffmpeg', + outFile: outputPath, + resolveAsset: (source) => resolveRegressionAsset(source, pathByAsset, migration), + }); + const hasAudio = await ffprobeHasAudio(render.outFile).catch(() => false); + const audioDurationSec = hasAudio ? await ffprobeDuration(render.outFile).catch(() => undefined) : undefined; + const volume = hasAudio ? await detectVolume(render.outFile).catch(() => null) : null; + const headVolume = hasAudio ? await detectVolumeWindow(render.outFile, 0, 0.12).catch(() => null) : null; + const tailStart = Math.max(0, (audioDurationSec ?? render.durationSec) - 0.12); + const tailVolume = hasAudio ? await detectVolumeWindow(render.outFile, tailStart, 0.12).catch(() => null) : null; + return { + id: input.id, + topic: input.topic, + migrationId: migration.id, + outputPath: render.outFile, + render: { + renderer: render.renderer, + durationSec: render.durationSec, + segmentCount: render.segmentCount, + usedDummy: render.usedDummy, + warnings: render.warnings, + }, + audio: { + hasAudio, + durationSec: audioDurationSec, + meanVolumeDb: volume?.meanVolumeDb, + maxVolumeDb: volume?.maxVolumeDb, + headMaxVolumeDb: headVolume?.maxVolumeDb, + tailMaxVolumeDb: tailVolume?.maxVolumeDb, + silentRisk: !hasAudio || (volume?.maxVolumeDb != null && volume.maxVolumeDb < -60), + headAbruptRisk: headVolume?.maxVolumeDb != null && headVolume.maxVolumeDb > -8, + tailAbruptRisk: tailVolume?.maxVolumeDb != null && tailVolume.maxVolumeDb > -8, + }, + qc: migration.qcReport, + debug: { + gapCount: migration.gaps.length, + fillCount: migration.fills.length, + timelineItemCount: migration.timeline.items.length, + }, + }; +} + +function detectVolume(file: string): Promise<{ meanVolumeDb?: number; maxVolumeDb?: number }> { + return detectVolumeWithArgs(['-i', file]); +} + +function detectVolumeWindow( + file: string, + startSec: number, + durationSec: number, +): Promise<{ meanVolumeDb?: number; maxVolumeDb?: number }> { + return detectVolumeWithArgs([ + '-ss', + startSec.toFixed(3), + '-t', + durationSec.toFixed(3), + '-i', + file, + ]); +} + +function detectVolumeWithArgs(inputArgs: string[]): Promise<{ meanVolumeDb?: number; maxVolumeDb?: number }> { + return new Promise((resolve, reject) => { + const p = spawn('ffmpeg', [ + '-hide_banner', + ...inputArgs, + '-af', + 'volumedetect', + '-f', + 'null', + '-', + ]); + let err = ''; + p.stderr.on('data', (d) => { + err += d.toString(); + }); + p.on('error', reject); + p.on('close', (code) => { + if (code !== 0) { + reject(new Error(err)); + return; + } + resolve({ + meanVolumeDb: parseDb(err.match(/mean_volume:\s*(-?\d+(?:\.\d+)?) dB/)?.[1]), + maxVolumeDb: parseDb(err.match(/max_volume:\s*(-?\d+(?:\.\d+)?) dB/)?.[1]), + }); + }); + }); +} + +function parseDb(value: string | undefined): number | undefined { + if (!value) return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +function resolveRegressionAsset( + source: TimelineSource, + pathByAsset: Map, + migration: MigrationPlan, +): string | null { + if (source.kind === 'user_asset') return pathByAsset.get(source.assetId) ?? null; + if (source.kind === 'fill_artifact') { + const fill = migration.fills.find((f) => f.id === source.fillArtifactId); + if (!fill) return null; + if (fill.source.startsWith('asset://')) { + const assetId = decodeURIComponent(fill.source.slice('asset://'.length)); + return pathByAsset.get(assetId) ?? null; + } + if (fill.source.startsWith('textcard://')) { + return `textcard://${encodeURIComponent(fill.displayText ?? '补全文案')}`; + } + return fill.source; + } + return source.path; +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/api/src/server/__tests__/server.test.ts b/apps/api/src/server/__tests__/server.test.ts index b956216..871de74 100644 --- a/apps/api/src/server/__tests__/server.test.ts +++ b/apps/api/src/server/__tests__/server.test.ts @@ -1,13 +1,16 @@ -import { createReadStream, writeFileSync } from 'node:fs'; +import { createReadStream, existsSync, writeFileSync } from 'node:fs'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import FormData from 'form-data'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { runRuleBasedMigration } from '../../core/migration'; import { sampleBlueprint } from '../../core/mocks/sample-blueprint'; import type { SampleAnalysis } from '../../core/sample'; +import { chatJson } from '../../llm/ark'; import { buildApp } from '../app'; +import { guardTimelineAudioForRender, subtitlesFromMigration } from '../jobs'; import { Store } from '../store'; const mockAnalysis: SampleAnalysis = { @@ -24,11 +27,23 @@ const mockAnalysis: SampleAnalysis = { }, scenes: [{ index: 1, atSec: 3 }], shotCount: 2, + transcriptCues: [], keyframes: [], coverPath: '/tmp/cover.jpg', evidence: [], }; +async function waitForTerminalJob(app: Awaited>, jobId: string) { + let body: { status: string; error?: string; result?: unknown } = { status: 'queued' }; + for (let i = 0; i < 80; i += 1) { + const res = await app.inject({ method: 'GET', url: `/api/jobs/${jobId}` }); + body = res.json(); + if (body.status === 'succeeded' || body.status === 'failed') return body; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + return body; +} + describe('api 骨架', () => { it('health 返回 ok', async () => { const app = await buildApp(); @@ -57,6 +72,7 @@ describe('api 骨架', () => { store, executors: { render: async (s, job) => { + expect(job.input).toEqual({ renderer: 'ffmpeg', stabilizeVideo: 'auto' }); s.updateJob(job.id, { status: 'succeeded', result: { outFile: 'x.mp4', durationSec: 30 } }); }, }, @@ -64,7 +80,11 @@ describe('api 骨架', () => { const project = await app.inject({ method: 'POST', url: '/api/projects', payload: { name: 'p' } }); const pid = project.json().id as string; - const render = await app.inject({ method: 'POST', url: `/api/projects/${pid}/render` }); + const render = await app.inject({ + method: 'POST', + url: `/api/projects/${pid}/render`, + payload: { renderer: 'ffmpeg' }, + }); expect(render.statusCode).toBe(202); const jobId = render.json().jobId as string; @@ -73,6 +93,275 @@ describe('api 骨架', () => { await app.close(); }); + it('render 字幕只来自 subtitle/caption shot,文字卡不重复生成底部字幕', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_subtitle_dataflow', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + id: 'asset_product', + mediaType: 'video', + assetTags: ['product_closeup'], + durationSec: 5, + confidence: 0.9, + summary: '产品特写', + }, + ], + topic: '便携咖啡杯', + durationSec: 12, + }); + const storyboardWithShots = migration.storyboard.filter((item) => item.shotId); + const subtitleShotId = storyboardWithShots[0].shotId!; + const textCardShot = storyboardWithShots[1]; + const textCardShotId = textCardShot.shotId!; + const textFill = { + id: 'fill_text_card_test', + slotId: textCardShot.slotId ?? 'slot_text_card_test', + kind: 'copy_completion' as const, + source: 'textcard://card-test', + displayText: '文字卡不重复', + track: 'video' as const, + startSec: textCardShot.startSec, + endSec: textCardShot.endSec, + }; + + const patched = { + ...migration, + fills: [...migration.fills, textFill], + directorPlan: { + ...migration.directorPlan, + shots: migration.directorPlan.shots.map((shot) => ({ + ...shot, + copyMode: + shot.shotId === subtitleShotId || shot.shotId === textCardShotId + ? ('subtitle' as const) + : ('none' as const), + copyRequired: shot.shotId === subtitleShotId || shot.shotId === textCardShotId, + })), + }, + storyboard: migration.storyboard.map((item) => ({ + ...item, + screenText: + item.shotId === subtitleShotId + ? '只显示这一条底部字幕' + : item.shotId === textCardShotId + ? '文字卡不重复' + : '', + })), + timeline: { + ...migration.timeline, + items: migration.timeline.items.map((item) => + item.shotRef === subtitleShotId + ? { ...item, source: { kind: 'raw' as const, path: '/tmp/real.mp4' } } + : item.shotRef === textCardShotId + ? { ...item, source: { kind: 'fill_artifact' as const, fillArtifactId: textFill.id } } + : item, + ), + }, + }; + + expect(subtitlesFromMigration(patched)).toEqual([ + { + startSec: storyboardWithShots[0].startSec, + endSec: storyboardWithShots[0].endSec, + text: '只显示这一条底部字幕', + }, + ]); + }); + + it('title_card / screen_text 如果落在真实素材上,也走下三分之一字幕', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_title_as_subtitle', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + id: 'asset_product', + mediaType: 'video', + assetTags: ['product_closeup'], + durationSec: 5, + confidence: 0.9, + summary: '产品特写', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + const firstShotId = migration.storyboard.find((item) => item.shotId)?.shotId!; + const patched = { + ...migration, + directorPlan: { + ...migration.directorPlan, + shots: migration.directorPlan.shots.map((shot) => ({ + ...shot, + copyMode: shot.shotId === firstShotId ? ('title_card' as const) : ('none' as const), + copyRequired: shot.shotId === firstShotId, + })), + }, + storyboard: migration.storyboard.map((item) => ({ + ...item, + cardCopy: item.shotId === firstShotId ? '真实画面上的标题' : '', + screenText: item.shotId === firstShotId ? '真实画面上的标题' : '', + })), + timeline: { + ...migration.timeline, + items: migration.timeline.items.map((item) => + item.shotRef === firstShotId + ? { ...item, source: { kind: 'raw' as const, path: '/tmp/real.mp4' } } + : item, + ), + }, + }; + + expect(subtitlesFromMigration(patched)).toEqual([ + { + startSec: migration.storyboard.find((item) => item.shotId === firstShotId)?.startSec, + endSec: migration.storyboard.find((item) => item.shotId === firstShotId)?.endSec, + text: '真实画面上的标题', + }, + ]); + }); + + it('已经作为 item overlayText 渲染的包装文案不会再生成底部字幕', () => { + const migration = runRuleBasedMigration({ + projectId: 'proj_overlay_subtitle_skip', + sampleId: sampleBlueprint.sourceSampleId, + blueprint: sampleBlueprint, + assets: [ + { + id: 'asset_product', + mediaType: 'video', + assetTags: ['product_closeup'], + durationSec: 5, + confidence: 0.9, + summary: '产品特写', + }, + ], + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + durationSec: 12, + }); + const firstShotId = migration.storyboard.find((item) => item.shotId)?.shotId!; + const patched = { + ...migration, + directorPlan: { + ...migration.directorPlan, + shots: migration.directorPlan.shots.map((shot) => ({ + ...shot, + copyMode: shot.shotId === firstShotId ? ('title_card' as const) : ('none' as const), + copyRequired: shot.shotId === firstShotId, + })), + }, + storyboard: migration.storyboard.map((item) => ({ + ...item, + screenText: item.shotId === firstShotId ? '真实画面上的标题' : '', + })), + timeline: { + ...migration.timeline, + items: migration.timeline.items.map((item) => + item.shotRef === firstShotId + ? { + ...item, + source: { kind: 'raw' as const, path: '/tmp/real.mp4' }, + overlayText: '真实画面上的标题', + } + : item, + ), + }, + }; + + expect(subtitlesFromMigration(patched)).toEqual([]); + }); + + it('render 前会替换旧时间线里的静音音源', async () => { + const store = new Store(); + const project = store.createProject('audio-guard'); + store.addAsset(project.id, { + id: 'silent_video', + mediaType: 'video', + assetTags: [], + durationSec: 14, + confidence: 1, + summary: '静音视频原声', + sourcePath: '/tmp/silent.mp4', + hasAudio: true, + audioMaxVolumeDb: -91, + silentAudioRisk: true, + }); + store.addAsset(project.id, { + id: 'usable_bgm', + mediaType: 'audio', + assetTags: [], + durationSec: 14, + confidence: 1, + summary: '可用 BGM', + sourcePath: '/tmp/bgm.mp3', + hasAudio: true, + audioMaxVolumeDb: -12, + silentAudioRisk: false, + isBgm: true, + }); + + const guarded = await guardTimelineAudioForRender(store, { + id: 'timeline_audio_guard', + projectId: project.id, + durationSec: 10, + items: [ + { + id: 'audio', + track: 'audio', + startSec: 0, + endSec: 10, + source: { kind: 'user_asset', assetId: 'silent_video' }, + }, + ], + }); + + expect(guarded.timeline.items[0].source).toEqual({ kind: 'user_asset', assetId: 'usable_bgm' }); + expect(guarded.warnings.join('\n')).toContain('已改用显式 BGM'); + }); + + it('render 前会为没有音频轨的旧时间线补可用音源', async () => { + const store = new Store(); + const project = store.createProject('audio-fill'); + store.addAsset(project.id, { + id: 'usable_bgm', + mediaType: 'audio', + assetTags: [], + durationSec: 14, + confidence: 1, + summary: '可用 BGM', + sourcePath: '/tmp/bgm.mp3', + hasAudio: true, + audioMaxVolumeDb: -12, + silentAudioRisk: false, + isBgm: true, + }); + + const guarded = await guardTimelineAudioForRender(store, { + id: 'timeline_missing_audio', + projectId: project.id, + durationSec: 10, + items: [ + { + id: 'video', + track: 'video', + startSec: 0, + endSec: 10, + source: { kind: 'raw', path: '/tmp/visual.mp4' }, + }, + ], + }); + + expect(guarded.timeline.items.find((item) => item.track === 'audio')?.source).toEqual({ + kind: 'user_asset', + assetId: 'usable_bgm', + }); + expect(guarded.warnings.join('\n')).toContain('时间线没有音频源'); + }); + it('analyze 缺 sampleId/sourcePath 返回 400', async () => { const app = await buildApp(); const p = await app.inject({ method: 'POST', url: '/api/projects', payload: { name: 'p' } }); @@ -111,6 +400,50 @@ describe('api 骨架', () => { await app.close(); }); + it('ASR endpoint 转写样例并回写 transcriptCues', async () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-asr-endpoint-')); + const mediaPath = join(dir, 'speech.mp4'); + writeFileSync(mediaPath, Buffer.from('fake media')); + vi.stubEnv('ASR_BASE_URL', 'https://asr.example.test/v1'); + vi.stubEnv('ASR_MODEL', 'unit-asr'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response( + JSON.stringify({ + segments: [ + { start: 0.2, end: 1.4, text: '开场抓停' }, + { start: 1.4, end: 2.8, text: '展示卖点', confidence: 0.87 }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + const store = new Store(); + const project = store.createProject('asr'); + const sample = store.addSample(project.id, mediaPath, 'speech.mp4'); + store.patchSample(sample.id, { analysis: { ...mockAnalysis, sampleId: sample.id, sourcePath: mediaPath } }); + const app = await buildApp({ store }); + + try { + const res = await app.inject({ + method: 'POST', + url: `/api/projects/${project.id}/asr`, + payload: { sampleId: sample.id }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().persisted).toBe(true); + expect(res.json().cues).toHaveLength(2); + expect(store.getSample(sample.id)?.analysis?.transcriptCues[0]?.text).toBe('开场抓停'); + } finally { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + await app.close(); + } + }); + it('structure job 写入 blueprint 且 GET /structure 可读', async () => { const store = new Store(); const p = store.createProject('p'); @@ -166,7 +499,15 @@ describe('api 骨架', () => { const app = await buildApp({ store, executors: {}, - migrate: async (input) => runRuleBasedMigration(input), + migrate: async (input) => { + await chatJson( + z.object({ ok: z.boolean() }), + [{ role: 'user', content: 'trace migration stub' }], + { traceName: 'migration_stub', chatFn: async () => JSON.stringify({ ok: true }) }, + ); + return runRuleBasedMigration(input); + }, + stockFn: async () => null, }); const asset = await app.inject({ @@ -186,9 +527,52 @@ describe('api 骨架', () => { url: `/api/projects/${p.id}/migrate`, payload: { sampleId: sample.id, topic: '便携咖啡杯', sellingPoints: ['保温 6 小时'] }, }); - expect(migrated.statusCode).toBe(201); - expect(migrated.json().migration.timeline.items.length).toBeGreaterThan(0); - expect(migrated.json().migration.gaps.length).toBeGreaterThan(0); + expect(migrated.statusCode).toBe(202); + const job = await waitForTerminalJob(app, migrated.json().jobId as string); + expect(job?.status).toBe('succeeded'); + const result = job?.result as { + migration: { timeline: { items: unknown[] }; gaps: unknown[] }; + debugTrace: Array<{ name: string; status: string }>; + }; + const migration = result.migration; + expect(migration.timeline.items.length).toBeGreaterThan(0); + expect(migration.gaps.length).toBeGreaterThan(0); + expect(result.debugTrace).toContainEqual(expect.objectContaining({ name: 'migration_stub', status: 'success' })); + + const stepsRes = await app.inject({ method: 'GET', url: `/api/jobs/${migrated.json().jobId}/steps` }); + expect(stepsRes.statusCode).toBe(200); + const steps = stepsRes.json().steps as Array<{ + name: string; + status: string; + output?: { kind?: string; source?: string; metrics?: Record; data?: unknown }; + }>; + expect(steps.map((step) => step.name)).toEqual([ + 'context', + 'beat_grid', + 'director', + 'match', + 'gap', + 'fill', + 'timeline', + 'qc', + ]); + expect(steps.every((step) => step.status === 'succeeded')).toBe(true); + expect(steps.find((step) => step.name === 'match')?.output).toMatchObject({ + kind: 'migration.match', + source: 'materialized_result', + metrics: { matchCount: expect.any(Number) }, + }); + expect(steps.find((step) => step.name === 'qc')?.output).toMatchObject({ + kind: 'migration.qc', + metrics: { totalScore: expect.any(Number) }, + }); + + const debugRes = await app.inject({ method: 'GET', url: `/api/jobs/${migrated.json().jobId}/debug` }); + expect(debugRes.statusCode).toBe(200); + expect(debugRes.json().debugTrace).toContainEqual( + expect.objectContaining({ name: 'migration_stub', status: 'success' }), + ); + expect(debugRes.json().steps).toHaveLength(8); const got = await app.inject({ method: 'GET', url: `/api/projects/${p.id}/migration` }); expect(got.statusCode).toBe(200); @@ -196,6 +580,123 @@ describe('api 骨架', () => { await app.close(); }); + it('关闭自动复用时仍保留用户显式 BGM', async () => { + const store = new Store(); + const p = store.createProject('p'); + const sample = store.addSample(p.id, '/tmp/x.mp4', 'x.mp4'); + store.patchSample(sample.id, { analysis: mockAnalysis, blueprint: sampleBlueprint }); + store.addAsset(p.id, { + mediaType: 'audio', + assetTags: [], + durationSec: 18, + isBgm: true, + confidence: 1, + summary: '用户指定 BGM', + }); + const app = await buildApp({ + store, + executors: {}, + migrate: async (input) => runRuleBasedMigration(input), + stockFn: async () => null, + }); + + const migrated = await app.inject({ + method: 'POST', + url: `/api/projects/${p.id}/migrate`, + payload: { + sampleId: sample.id, + topic: '便携咖啡杯', + sellingPoints: ['保温 6 小时'], + reuseUploadedBgm: false, + }, + }); + expect(migrated.statusCode).toBe(202); + const job = await waitForTerminalJob(app, migrated.json().jobId as string); + expect(job?.status).toBe('succeeded'); + const migration = (job?.result as { migration: { + timeline: { items: Array<{ track: string }> }; + evidence: Array<{ type: string; detail: string }>; + } }).migration; + expect(migration.timeline.items.some((item: { track: string }) => item.track === 'audio')).toBe(true); + expect( + migration.evidence.some((e: { type: string; detail: string }) => e.type === 'bgm' && e.detail.includes('未指定 BGM')), + ).toBe(false); + await app.close(); + }); + + it('全局样例学习草案可确认入库并更新', async () => { + const store = new Store(); + const project = store.createProject('p'); + const sample = store.addSample('__global_pattern_library__', '/tmp/x.mp4', 'x.mp4'); + store.patchSample(sample.id, { analysis: { ...mockAnalysis, sampleId: sample.id }, blueprint: sampleBlueprint }); + const app = await buildApp({ store, executors: {} }); + + const draftRes = await app.inject({ + method: 'POST', + url: '/api/sample-learning/draft', + payload: { sampleId: sample.id }, + }); + expect(draftRes.statusCode).toBe(200); + const draft = draftRes.json().draft; + expect(draft.scope).toBe('global'); + expect(draft.projectId).toBeUndefined(); + expect(draft.learnedThings.formula).toContain('->'); + expect(draft.dbData.sourceSampleId).toBe(sample.id); + expect(draft.dbData.scope).toBe('global'); + expect(draft.dbData.learnedDimensions.scriptStructure.formula).toContain('->'); + expect(draft.dbData.learnedDimensions.subtitleStyle.density).toBeTruthy(); + expect(draft.dbData.learnedDimensions.visualPackaging.overlayStyle).toBeTruthy(); + expect(draft.dbData.learnedDimensions.transitions.style).toBeTruthy(); + expect(draft.dbData.learnedDimensions.bgmSync.syncStrategy).toBeTruthy(); + + const saveRes = await app.inject({ + method: 'POST', + url: '/api/sample-patterns', + payload: draft.dbData, + }); + expect(saveRes.statusCode).toBe(201); + const pattern = saveRes.json().pattern; + expect(pattern.projectId).toBeUndefined(); + + const secondSaveRes = await app.inject({ + method: 'POST', + url: '/api/sample-patterns', + payload: { ...draft.dbData, id: `${draft.dbData.id}_second`, name: '第二个模式' }, + }); + expect(secondSaveRes.statusCode).toBe(201); + + const listRes = await app.inject({ method: 'GET', url: '/api/sample-patterns' }); + expect(listRes.statusCode).toBe(200); + expect(listRes.json().patterns).toHaveLength(2); + + const projectListRes = await app.inject({ method: 'GET', url: `/api/projects/${project.id}/sample-patterns` }); + expect(projectListRes.statusCode).toBe(200); + expect(projectListRes.json().patterns).toHaveLength(2); + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/sample-patterns/${pattern.id}`, + payload: { ...pattern, name: '更新后的模式' }, + }); + expect(updateRes.statusCode).toBe(200); + expect(updateRes.json().pattern.name).toBe('更新后的模式'); + + const deleteRes = await app.inject({ method: 'DELETE', url: `/api/sample-patterns/${pattern.id}` }); + expect(deleteRes.statusCode).toBe(204); + const oneLeftListRes = await app.inject({ method: 'GET', url: '/api/sample-patterns' }); + expect(oneLeftListRes.json().patterns).toHaveLength(1); + + const missingDeleteRes = await app.inject({ method: 'DELETE', url: `/api/sample-patterns/${pattern.id}` }); + expect(missingDeleteRes.statusCode).toBe(404); + + const deleteAllRes = await app.inject({ method: 'DELETE', url: '/api/sample-patterns' }); + expect(deleteAllRes.statusCode).toBe(200); + expect(deleteAllRes.json()).toEqual({ deletedCount: 1 }); + const emptyListRes = await app.inject({ method: 'GET', url: '/api/sample-patterns' }); + expect(emptyListRes.json().patterns).toHaveLength(0); + await app.close(); + }); + it('上传样例 multipart 触发 analyze job', async () => { const dir = mkdtempSync(join(tmpdir(), 'vf-upload-')); const videoPath = join(dir, 'tiny.mp4'); @@ -236,4 +737,324 @@ describe('api 骨架', () => { rmSync(dir, { recursive: true, force: true }); await app.close(); }); + + it('上传素材 multipart 自动打标(注入 tagFn)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-asset-')); + const assetPath = join(dir, 'clip.mp4'); + writeFileSync(assetPath, Buffer.from([0, 0, 0, 0])); + + const store = new Store(); + const app = await buildApp({ + store, + tagFn: async () => ({ assetTags: ['talking_head'], summary: '车手采访', confidence: 0.82 }), + }); + const project = await app.inject({ method: 'POST', url: '/api/projects', payload: { name: 'a' } }); + const pid = project.json().id as string; + + const form = new FormData(); + form.append('file', createReadStream(assetPath), { filename: 'clip.mp4', contentType: 'video/mp4' }); + + const res = await app.inject({ + method: 'POST', + url: `/api/projects/${pid}/assets`, + payload: form, + headers: form.getHeaders(), + }); + expect(res.statusCode).toBe(201); + const asset = res.json().asset as { assetTags: string[]; summary: string; confidence: number }; + expect(asset.assetTags).toEqual(['talking_head']); + expect(asset.summary).toBe('车手采访'); + expect(asset.confidence).toBe(0.82); + + rmSync(dir, { recursive: true, force: true }); + await app.close(); + }); + + it('上传音频素材会保存为 audio asset 且不触发 VLM 打标', async () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-audio-asset-')); + const audioPath = join(dir, 'bgm.mp3'); + writeFileSync(audioPath, Buffer.from([0, 0, 0, 0])); + + const store = new Store(); + let tagCalled = false; + const app = await buildApp({ + store, + tagFn: async () => { + tagCalled = true; + return null; + }, + }); + + const project = await app.inject({ method: 'POST', url: '/api/projects', payload: { name: 'audio' } }); + const pid = project.json().id as string; + + const form = new FormData(); + form.append('file', createReadStream(audioPath), { filename: 'bgm.mp3', contentType: 'audio/mpeg' }); + form.append('summary', '轻快 BGM'); + + const res = await app.inject({ + method: 'POST', + url: `/api/projects/${pid}/assets`, + payload: form, + headers: form.getHeaders(), + }); + expect(res.statusCode).toBe(201); + expect(res.json().asset).toMatchObject({ + mediaType: 'audio', + assetTags: [], + summary: '轻快 BGM', + durationSec: 5, + filename: 'bgm.mp3', + }); + expect(tagCalled).toBe(false); + + rmSync(dir, { recursive: true, force: true }); + await app.close(); + }); + + it('删除项目素材会移除记录和上传文件', async () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-delete-asset-')); + const imagePath = join(dir, 'scene.jpg'); + writeFileSync(imagePath, Buffer.from([1, 2, 3, 4])); + + const store = new Store(); + const app = await buildApp({ store, tagFn: async () => null }); + + const project = await app.inject({ method: 'POST', url: '/api/projects', payload: { name: 'delete asset' } }); + const pid = project.json().id as string; + + const form = new FormData(); + form.append('file', createReadStream(imagePath), { filename: 'scene.jpg', contentType: 'image/jpeg' }); + + const uploaded = await app.inject({ + method: 'POST', + url: `/api/projects/${pid}/assets`, + payload: form, + headers: form.getHeaders(), + }); + expect(uploaded.statusCode).toBe(201); + const asset = uploaded.json().asset as { id: string; sourcePath: string }; + expect(existsSync(asset.sourcePath)).toBe(true); + + const deleted = await app.inject({ method: 'DELETE', url: `/api/projects/${pid}/assets/${asset.id}` }); + expect(deleted.statusCode).toBe(204); + expect(existsSync(asset.sourcePath)).toBe(false); + + const listed = await app.inject({ method: 'GET', url: `/api/projects/${pid}/assets` }); + expect(listed.json().assets).toEqual([]); + + rmSync(dir, { recursive: true, force: true }); + await app.close(); + }); + + it('手动选择 b_roll 上传视觉素材时仍会合并自动视觉语义', async () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-manual-broll-')); + const imagePath = join(dir, 'scene.jpg'); + writeFileSync(imagePath, Buffer.from([1, 2, 3, 4])); + + const store = new Store(); + const app = await buildApp({ + store, + tagFn: async () => ({ + assetTags: ['product_closeup'], + storyRoles: ['context', 'mood'], + narrativeUse: 'context', + visualFunctions: ['establish_context', 'show_scale', 'show_emotion'], + shotScale: 'wide', + visualMood: ['scenic', 'calm'], + visualClusterId: 'cluster_lake', + summary: '九寨沟湖水远景', + confidence: 0.84, + }), + }); + + const project = await app.inject({ method: 'POST', url: '/api/projects', payload: { name: 'manual tags' } }); + const pid = project.json().id as string; + + const form = new FormData(); + form.append('file', createReadStream(imagePath), { filename: 'scene.jpg', contentType: 'image/jpeg' }); + form.append('assetTags', 'b_roll'); + form.append('summary', '用户手动标注的旅行素材'); + + const res = await app.inject({ + method: 'POST', + url: `/api/projects/${pid}/assets`, + payload: form, + headers: form.getHeaders(), + }); + + expect(res.statusCode).toBe(201); + expect(res.json().asset).toMatchObject({ + mediaType: 'image', + assetTags: ['b_roll', 'product_closeup'], + storyRoles: ['context', 'mood'], + narrativeUse: 'context', + visualFunctions: ['establish_context', 'show_scale', 'show_emotion'], + shotScale: 'wide', + visualMood: ['scenic', 'calm'], + visualClusterId: 'cluster_lake', + summary: '用户手动标注的旅行素材', + confidence: 0.84, + }); + + rmSync(dir, { recursive: true, force: true }); + await app.close(); + }); + + it('VLM 不可用时上传素材使用人工用途和景别作为兜底', async () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-manual-fallback-')); + const imagePath = join(dir, 'usage.jpg'); + writeFileSync(imagePath, Buffer.from([1, 2, 3, 4])); + + const store = new Store(); + const app = await buildApp({ store, tagFn: async () => null }); + const project = await app.inject({ method: 'POST', url: '/api/projects', payload: { name: 'fallback tags' } }); + const pid = project.json().id as string; + + const form = new FormData(); + form.append('file', createReadStream(imagePath), { filename: 'usage.jpg', contentType: 'image/jpeg' }); + form.append('assetTags', 'usage_demo'); + form.append('visualFunctions', 'show_action,show_progression'); + form.append('shotScale', 'close'); + + const res = await app.inject({ + method: 'POST', + url: `/api/projects/${pid}/assets`, + payload: form, + headers: form.getHeaders(), + }); + + expect(res.statusCode).toBe(201); + expect(res.json().asset).toMatchObject({ + mediaType: 'image', + assetTags: ['usage_demo'], + visualFunctions: ['show_action', 'show_progression'], + shotScale: 'close', + summary: 'usage.jpg', + }); + + rmSync(dir, { recursive: true, force: true }); + await app.close(); + }); + + it('素材入库后可以用人工修正画面用途和景别', async () => { + const store = new Store(); + const project = store.createProject('patch asset tags'); + const asset = store.addAsset(project.id, { + mediaType: 'image', + assetTags: ['b_roll'], + confidence: 0.72, + summary: '自动识别的风景素材', + }); + const app = await buildApp({ store }); + + const res = await app.inject({ + method: 'PATCH', + url: `/api/projects/${project.id}/assets/${asset.id}`, + payload: { + assetTags: ['product_closeup'], + visualFunctions: ['show_detail'], + shotScale: 'macro', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().asset).toMatchObject({ + id: asset.id, + assetTags: ['product_closeup'], + visualFunctions: ['show_detail'], + shotScale: 'macro', + confidence: 0.72, + }); + expect(store.getAsset(asset.id)?.assetTags).toEqual(['product_closeup']); + expect(store.getAsset(asset.id)?.visualFunctions).toEqual(['show_detail']); + expect(store.getAsset(asset.id)?.shotScale).toBe('macro'); + + const cleared = await app.inject({ + method: 'PATCH', + url: `/api/projects/${project.id}/assets/${asset.id}`, + payload: { shotScale: null }, + }); + expect(cleared.statusCode).toBe(200); + expect(store.getAsset(asset.id)?.shotScale).toBeUndefined(); + + await app.close(); + }); + + it('上传 HEIC 图片会先转换为 JPEG 再进入素材库和打标', async () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-heic-asset-')); + const heicPath = join(dir, 'photo.heic'); + writeFileSync(heicPath, Buffer.from([0, 0, 0, 0])); + + const store = new Store(); + let normalizedInput = ''; + let tagInput = ''; + const app = await buildApp({ + store, + normalizeImageFn: async ({ sourcePath }) => { + normalizedInput = sourcePath; + const converted = sourcePath.replace(/\.heic$/i, '.jpg'); + writeFileSync(converted, Buffer.from([1, 2, 3])); + return { sourcePath: converted, convertedFrom: sourcePath }; + }, + tagFn: async ({ path, mediaType }) => { + tagInput = path; + return { + assetTags: ['product_closeup'], + summary: `converted ${mediaType}`, + confidence: 0.88, + }; + }, + }); + + const project = await app.inject({ method: 'POST', url: '/api/projects', payload: { name: 'heic' } }); + const pid = project.json().id as string; + + const form = new FormData(); + form.append('file', createReadStream(heicPath), { filename: 'photo.heic', contentType: 'image/heic' }); + + const res = await app.inject({ + method: 'POST', + url: `/api/projects/${pid}/assets`, + payload: form, + headers: form.getHeaders(), + }); + expect(res.statusCode).toBe(201); + expect(normalizedInput).toMatch(/\.heic$/); + expect(tagInput).toMatch(/\.jpg$/); + expect(res.json().asset).toMatchObject({ + mediaType: 'image', + assetTags: ['product_closeup'], + summary: 'converted image', + filename: 'photo.heic', + }); + expect(res.json().asset.sourcePath).toMatch(/\.jpg$/); + + rmSync(dir, { recursive: true, force: true }); + await app.close(); + }); + + it('生成多版本:3 个差异明确的版本(规则版,无需 key)', async () => { + const store = new Store(); + const p = store.createProject('p'); + const sample = store.addSample(p.id, '/tmp/x.mp4', 'x.mp4'); + store.patchSample(sample.id, { analysis: mockAnalysis, blueprint: sampleBlueprint }); + const app = await buildApp({ store, executors: {} }); + + const res = await app.inject({ + method: 'POST', + url: `/api/projects/${p.id}/versions`, + payload: { sampleId: sample.id, topic: 'demo' }, + }); + expect(res.statusCode).toBe(201); + const versions = res.json().versions as { + id: string; + blueprint: { rhythmStructure: { cutDensity: string } }; + }[]; + expect(versions).toHaveLength(3); + expect(versions.find((v) => v.id === 'fast')?.blueprint.rhythmStructure.cutDensity).toBe('high'); + expect(versions.find((v) => v.id === 'cinematic')?.blueprint.rhythmStructure.cutDensity).toBe('low'); + + await app.close(); + }); }); diff --git a/apps/api/src/server/__tests__/store.test.ts b/apps/api/src/server/__tests__/store.test.ts index 7e2ad0a..a26faee 100644 --- a/apps/api/src/server/__tests__/store.test.ts +++ b/apps/api/src/server/__tests__/store.test.ts @@ -1,7 +1,55 @@ +import { createRequire } from 'node:module'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { runRuleBasedMigration } from '../../core/migration'; import { sampleBlueprint } from '../../core/mocks/sample-blueprint'; import { Store } from '../store'; +const require = createRequire(import.meta.url); +const sqliteAvailable = (() => { + try { + require('node:sqlite'); + return true; + } catch { + return false; + } +})(); +const apiRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..'); + +function learnedPatternFixture(overrides: Record = {}) { + return { + id: 'pattern_fixture', + scope: 'global' as const, + sourceSampleId: 'sample_fixture', + name: 'Seed 模式', + summary: '可复用 seed 结构', + videoGenre: 'showcase' as const, + tags: ['seed', 'showcase'], + reusablePatternName: 'Seed 展示结构', + formula: 'hook -> develop -> closing', + source: { filename: 'seed.mp4', durationSec: 30, aspectRatio: '1080:1920', shotCount: 8 }, + segments: [ + { + role: 'hook' as const, + durationRatio: 1, + intent: '抓停', + copyPattern: '反差', + watchingPurpose: '开场抓停', + }, + ], + pacing: { durationSec: 30, shotCount: 8, avgShotSec: 3.75, cutDensity: 'medium' as const, peakAt: 0.7, beatHints: [] }, + slotNeeds: [], + evidence: [], + rationale: '', + createdAt: '2026-06-09T00:00:00.000Z', + updatedAt: '2026-06-09T00:00:00.000Z', + ...overrides, + }; +} + describe('Store 样例域', () => { it('addSample / listSamples / patchSample', () => { const store = new Store(); @@ -22,4 +70,241 @@ describe('Store 样例域', () => { expect(latest?.id).toBe(b.id); expect(store.getLatestSampleForStructure(p.id, a.id)?.id).toBe(a.id); }); + + it('saveLearnedPattern / listLearnedPatterns / update', () => { + const store = new Store(); + const p = store.createProject('p'); + const pattern = { + id: 'pattern_1', + scope: 'project' as const, + projectId: p.id, + sourceSampleId: 'sample_1', + name: '样例模式', + summary: '可复用结构', + videoGenre: 'product' as const, + tags: ['product', 'high'], + reusablePatternName: '带货:反差 -> CTA', + formula: '反差 -> 展开 -> CTA', + source: { filename: 'sample.mp4', durationSec: 30, aspectRatio: '1080:1920', shotCount: 12 }, + segments: [ + { + role: 'hook' as const, + durationRatio: 1, + intent: '抓停', + copyPattern: '反差', + watchingPurpose: '开场抓停', + }, + ], + pacing: { durationSec: 30, shotCount: 12, avgShotSec: 2.5, cutDensity: 'high' as const, peakAt: 0.7, beatHints: [] }, + slotNeeds: [], + evidence: [], + rationale: '', + createdAt: '2026-05-24T00:00:00.000Z', + updatedAt: '2026-05-24T00:00:00.000Z', + }; + + const saved = store.saveLearnedPattern(pattern); + expect(store.listLearnedPatterns(p.id)).toHaveLength(1); + expect(saved.learnedDimensions.scriptStructure.formula).toBe('unknown'); + const updated = store.saveLearnedPattern({ ...saved, name: '更新模式' }); + expect(updated.createdAt).toBe(saved.createdAt); + expect(store.getLearnedPattern(saved.id)?.name).toBe('更新模式'); + expect(store.deleteLearnedPattern(saved.id)).toBe(true); + expect(store.getLearnedPattern(saved.id)).toBeNull(); + expect(store.deleteLearnedPattern(saved.id)).toBe(false); + }); + + it('global learned patterns 可在任意项目中复用', () => { + const store = new Store(); + const p = store.createProject('p'); + const pattern = { + id: 'pattern_global_1', + scope: 'global' as const, + sourceSampleId: 'sample_1', + name: '全局样例模式', + summary: '可复用结构', + videoGenre: 'product' as const, + tags: ['product', 'high'], + reusablePatternName: '带货:反差 -> CTA', + formula: '反差 -> 展开 -> CTA', + source: { filename: 'sample.mp4', durationSec: 30, aspectRatio: '1080:1920', shotCount: 12 }, + segments: [ + { + role: 'hook' as const, + durationRatio: 1, + intent: '抓停', + copyPattern: '反差', + watchingPurpose: '开场抓停', + }, + ], + pacing: { durationSec: 30, shotCount: 12, avgShotSec: 2.5, cutDensity: 'high' as const, peakAt: 0.7, beatHints: [] }, + slotNeeds: [], + evidence: [], + rationale: '', + createdAt: '2026-05-24T00:00:00.000Z', + updatedAt: '2026-05-24T00:00:00.000Z', + }; + + store.saveLearnedPattern(pattern); + expect(store.listGlobalLearnedPatterns()).toHaveLength(1); + expect(store.listLearnedPatterns(p.id)).toHaveLength(1); + }); + + it('fresh learned pattern store 会从 seed 初始化全局样例库', () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-store-seed-')); + try { + const jsonPath = join(dir, 'data', 'learned-patterns.json'); + const seedPath = join(dir, 'global-learned-patterns.seed.json'); + writeFileSync(seedPath, JSON.stringify([learnedPatternFixture({ id: 'pattern_seed_global' })])); + + const first = new Store({ learnedPatternDbPath: jsonPath, learnedPatternSeedPath: seedPath }); + const seeded = first.getLearnedPattern('pattern_seed_global'); + expect(seeded?.name).toBe('Seed 模式'); + expect(seeded?.source.sourcePath).toBeUndefined(); + expect(first.listGlobalLearnedPatterns()).toHaveLength(1); + expect(existsSync(jsonPath)).toBe(true); + + const second = new Store({ learnedPatternDbPath: jsonPath, learnedPatternSeedPath: seedPath }); + expect(second.listGlobalLearnedPatterns()).toHaveLength(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('本地 learned pattern DB 已存在时不重新导入 seed', () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-store-existing-json-')); + try { + const jsonPath = join(dir, 'data', 'learned-patterns.json'); + const seedPath = join(dir, 'global-learned-patterns.seed.json'); + writeFileSync(seedPath, JSON.stringify([learnedPatternFixture({ id: 'pattern_seed_global' })])); + mkdirSync(join(dir, 'data'), { recursive: true }); + writeFileSync(jsonPath, '[]'); + + const store = new Store({ learnedPatternDbPath: jsonPath, learnedPatternSeedPath: seedPath }); + expect(store.listGlobalLearnedPatterns()).toHaveLength(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('仓库全局样例 seed 干净且可初始化', () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-store-repo-seed-')); + try { + const jsonPath = join(dir, 'data', 'learned-patterns.json'); + const seedPath = join(apiRoot, 'seeds', 'global-learned-patterns.seed.json'); + const store = new Store({ learnedPatternDbPath: jsonPath, learnedPatternSeedPath: seedPath }); + const patterns = store.listGlobalLearnedPatterns(); + expect(patterns).toHaveLength(25); + const serialized = JSON.stringify(patterns); + expect(serialized).not.toContain('/Users/'); + expect(serialized).not.toContain('apps/api/uploads'); + expect(serialized).not.toContain('sourcePath'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.runIf(sqliteAvailable)('sqlitePath 可恢复 project / sample / asset / migration / job', () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-store-sqlite-')); + try { + const sqlitePath = join(dir, 'visionforge.sqlite'); + const first = new Store({ sqlitePath }); + const project = first.createProject('durable'); + const sample = first.addSample(project.id, '/tmp/sample.mp4', 'sample.mp4'); + first.patchSample(sample.id, { blueprint: sampleBlueprint }); + const asset = first.addAsset(project.id, { + mediaType: 'video', + assetTags: ['product_closeup'], + durationSec: 5, + confidence: 0.9, + summary: '产品特写', + }); + const migration = first.saveMigration(runRuleBasedMigration({ + projectId: project.id, + sampleId: sample.id, + blueprint: sampleBlueprint, + assets: [asset], + topic: '持久化测试', + durationSec: 12, + })); + const job = first.createJob(project.id, 'migration', { topic: '持久化测试' }); + first.updateJob(job.id, { status: 'succeeded', result: { migrationId: migration.id } }); + const step = first.createJobStep(project.id, job.id, { + name: 'director', + label: 'Director / Expert 迁移', + order: 1, + input: { topic: '持久化测试' }, + }); + first.updateJobStep(step.id, { + status: 'succeeded', + output: { shotCount: migration.directorPlan.shots.length }, + startedAt: '2026-06-04T00:00:00.000Z', + completedAt: '2026-06-04T00:00:01.000Z', + durationMs: 1000, + }); + + const second = new Store({ sqlitePath }); + expect(second.getProject(project.id)?.name).toBe('durable'); + expect(second.getSample(sample.id)?.blueprint?.id).toBe(sampleBlueprint.id); + expect(second.getAsset(asset.id)?.summary).toBe('产品特写'); + expect(second.getLatestMigration(project.id)?.topic).toBe('持久化测试'); + expect(second.getJob(job.id)?.status).toBe('succeeded'); + const steps = second.listJobSteps(job.id); + expect(steps).toHaveLength(1); + expect(steps[0]).toMatchObject({ + name: 'director', + status: 'succeeded', + output: { shotCount: migration.directorPlan.shots.length }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.runIf(sqliteAvailable)('旧 JSON learned patterns 会补写到 SQLite', () => { + const dir = mkdtempSync(join(tmpdir(), 'vf-store-json-to-sqlite-')); + try { + const jsonPath = join(dir, 'learned-patterns.json'); + const sqlitePath = join(dir, 'visionforge.sqlite'); + const pattern = { + id: 'pattern_from_json', + scope: 'global' as const, + sourceSampleId: 'sample_1', + name: '旧 JSON 模式', + summary: '从旧 JSON 迁移', + videoGenre: 'product' as const, + tags: ['product'], + reusablePatternName: '带货结构', + formula: 'hook -> proof -> CTA', + source: { filename: 'sample.mp4', durationSec: 30, aspectRatio: '1080:1920', shotCount: 12 }, + segments: [ + { + role: 'hook' as const, + durationRatio: 1, + intent: '抓停', + copyPattern: '反差', + watchingPurpose: '开场抓停', + }, + ], + pacing: { durationSec: 30, shotCount: 12, avgShotSec: 2.5, cutDensity: 'medium' as const, peakAt: 0.7, beatHints: [] }, + slotNeeds: [], + evidence: [], + rationale: '', + createdAt: '2026-05-24T00:00:00.000Z', + updatedAt: '2026-05-24T00:00:00.000Z', + }; + const canonicalPattern = new Store().saveLearnedPattern(pattern); + writeFileSync(jsonPath, JSON.stringify([canonicalPattern])); + + const first = new Store({ learnedPatternDbPath: jsonPath, sqlitePath }); + expect(first.getLearnedPattern(pattern.id)?.name).toBe('旧 JSON 模式'); + expect(first.getLearnedPattern(pattern.id)?.qualityTags.patternDepth).toBe('thin_pattern'); + + const second = new Store({ sqlitePath }); + expect(second.getLearnedPattern(pattern.id)?.summary).toBe('从旧 JSON 迁移'); + expect(second.getLearnedPattern(pattern.id)?.qualityTags.patternDepth).toBe('thin_pattern'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/api/src/server/app.ts b/apps/api/src/server/app.ts index 0edc0da..d6906dc 100644 --- a/apps/api/src/server/app.ts +++ b/apps/api/src/server/app.ts @@ -1,32 +1,117 @@ import { randomUUID } from 'node:crypto'; import { createReadStream, existsSync } from 'node:fs'; -import { extname } from 'node:path'; +import { rm } from 'node:fs/promises'; +import { extname, join, resolve } from 'node:path'; import multipart from '@fastify/multipart'; import Fastify, { type FastifyInstance } from 'fastify'; import { type ExpertMigrationInput, runExpertMigration } from '../agents/expertMigration'; -import { AssetTag, MediaType, type AssetTag as AssetTagT, type MediaType as MediaTypeT } from '../core/enums'; -import type { MigrationPlan } from '../core/migration'; +import { enhanceFillsWithStock } from '../agents/fillWithStock'; +import { runSampleLearningAgent } from '../agents/sampleLearningAgent'; +import { searchAndDownloadStock, type StockSearchFn } from '../agents/stockFootage'; +import { tagAsset } from '../agents/tagAsset'; +import { getArkConfig } from '../config'; +import { + AssetTag, + MediaType, + MigrationIntent, + ReferenceClipMode, + StoryFunction, + TemplateAdaptationMode, + VisualGapMode, + VisualFunction, + type AssetTag as AssetTagT, + type MediaType as MediaTypeT, + type SegmentRole as SegmentRoleT, + type StoryFunction as StoryFunctionT, + type VisualFunction as VisualFunctionT, +} from '../core/enums'; +import { MigrationPlan as MigrationPlanSchema, hasUsableAudio, type MigrationPlan } from '../core/migration'; +import { VideoStructureBlueprint as VideoStructureBlueprintSchema } from '../core/blueprint'; +import { chatJson, collectDebugTrace } from '../llm/ark'; +import { z } from 'zod'; +import { generateVersions } from '../core/versions'; +import { MigrationControls, type MigrationControls as MigrationControlsT } from '../core/migrationControls'; +import { LearnedSamplePattern, type LearnedSamplePattern as LearnedSamplePatternT } from '../core/sampleLearning'; +import type { LearnedSampleScope } from '../core/sampleLearning'; +import type { SampleAnalysis } from '../core/sample'; +import type { ReferenceAsset } from '../core/slot'; +import { isCarouselTemplateProfile } from '../core/template'; +import { transcribeAudio } from '../media/asr'; +import { SILENT_AUDIO_MAX_VOLUME_DB, probeAudioVolume } from '../media/audioQuality'; +import { detectMusicStructure, type DetectedMusicStructure } from '../media/beatDetect'; +import { probeVideo } from '../media/probe'; +import { ffprobeDuration, ffprobeHasAudio } from '../render/ffmpeg'; +import type { RenderBackend } from '../render/renderVideo'; +import { normalizeImageAsset, type ImageNormalizeFn } from './imageAssets'; import { defaultExecutors, type JobExecutor } from './jobs'; +import type { VideoStabilizationMode } from '../render/renderTimeline'; import { ALLOWED_ASSET_EXT, MAX_ASSET_BYTES, MAX_UPLOAD_BYTES, + UPLOADS_ROOT, assetUploadPath, sampleUploadPath, } from './paths'; -import { type Job, type JobKind, Store, isTerminal } from './store'; +import { type Job, type JobKind, type Sample, Store, isTerminal } from './store'; import { UploadError, saveMultipartFile } from './upload'; export type MigrateFn = (input: ExpertMigrationInput) => Promise | MigrationPlan; +export interface AssetTagResult { + assetTags: AssetTagT[]; + storyRoles?: StoryFunctionT[]; + narrativeUse?: StoryFunctionT; + visualFunctions?: VisualFunctionT[]; + shotScale?: 'wide' | 'medium' | 'close' | 'macro'; + visualMood?: string[]; + visualClusterId?: string; + summary: string; + confidence: number; +} +export type AssetTagFn = (input: { + path: string; + mediaType: MediaTypeT; + durationSec?: number; +}) => Promise; + +const GLOBAL_LIBRARY_ID = '__global_pattern_library__'; + export interface AppDeps { store?: Store; /** 可注入 executor,便于测试时替换掉真实渲染 / FFmpeg / LLM。 */ executors?: Partial>; /** 可注入迁移实现;默认走剪辑专家(LLM)。测试可注入规则版以离线运行。 */ migrate?: MigrateFn; + /** 可注入素材打标;默认 VLM(无 ARK key 或失败时返回 null → 回退默认标签)。 */ + tagFn?: AssetTagFn; + /** 可注入 stock 检索;默认 Pexels(无 PEXELS_API_KEY 或失败时返回 null → 保留 text_card)。 */ + stockFn?: StockSearchFn; + /** 可注入图片规范化;默认把 HEIC / HEIF 转成 JPEG 后进入后续 pipeline。 */ + normalizeImageFn?: ImageNormalizeFn; } +/** 默认素材打标:有 ARK key 时走 VLM,否则 / 失败时返回 null(调用方回退默认标签)。 */ +const defaultTagFn: AssetTagFn = async ({ path, mediaType, durationSec }) => { + if (!getArkConfig()) return null; + try { + const t = await tagAsset({ sourcePath: path, mediaType, durationSec }); + return { + assetTags: t.assetTags, + storyRoles: t.storyRoles, + narrativeUse: t.narrativeUse, + visualFunctions: t.visualFunctions, + shotScale: t.shotScale, + visualMood: t.visualMood, + visualClusterId: t.visualClusterId, + summary: t.summary, + confidence: t.confidence, + }; + } catch { + return null; + } +}; + function runJob(store: Store, executors: Partial>, job: Job) { const exec = executors[job.kind]; if (exec) void exec(store, job); @@ -58,15 +143,63 @@ function parseAssetTags(value: unknown, fallback: AssetTagT[]): AssetTagT[] { return tags.length ? [...new Set(tags)] : fallback; } +function parseOptionalAssetTags(value: unknown): AssetTagT[] | undefined { + if (value === undefined) return undefined; + return parseAssetTags(value, []); +} + +function parseStoryFunctions(value: unknown, fallback: StoryFunctionT[] = []): StoryFunctionT[] { + const raw = Array.isArray(value) + ? value.join(',') + : typeof value === 'string' + ? value + : String(value ?? ''); + const parsed = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const tags = parsed + .map((tag) => StoryFunction.safeParse(tag)) + .filter((r): r is { success: true; data: StoryFunctionT } => r.success) + .map((r) => r.data); + return tags.length ? [...new Set(tags)] : fallback; +} + +function parseVisualFunctions(value: unknown): VisualFunctionT[] { + const raw = Array.isArray(value) + ? value.join(',') + : typeof value === 'string' + ? value + : String(value ?? ''); + const parsed = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return Array.from( + new Set( + parsed + .map((tag) => VisualFunction.safeParse(tag)) + .filter((r): r is { success: true; data: VisualFunctionT } => r.success) + .map((r) => r.data), + ), + ); +} + +function uniqueValues(values: T[]): T[] { + return Array.from(new Set(values)); +} + function inferMediaType(filename: string): MediaTypeT { const ext = extname(filename).toLowerCase(); - if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return 'image'; + if (['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif'].includes(ext)) return 'image'; + if (['.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg'].includes(ext)) return 'audio'; return 'video'; } function defaultTagsFor(mediaType: MediaTypeT): AssetTagT[] { if (mediaType === 'text') return ['text_card']; if (mediaType === 'image') return ['product_closeup']; + if (mediaType === 'audio') return []; return ['b_roll']; } @@ -75,6 +208,125 @@ function parsePositiveNumber(value: unknown): number | undefined { return Number.isFinite(n) && n > 0 ? n : undefined; } +function parseRenderBackend(value: unknown): RenderBackend | undefined { + return value === 'ffmpeg' || value === 'remotion' ? value : undefined; +} + +function parseOptionalBoolean(value: unknown): boolean | undefined { + if (value == null) return undefined; + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + if (value === 'true') return true; + if (value === 'false') return false; + } + return undefined; +} + +function parseStabilizationMode(value: unknown): VideoStabilizationMode | undefined { + if (value === 'auto') return 'auto'; + return parseOptionalBoolean(value); +} + +async function probeAssetDuration(mediaType: MediaTypeT, sourcePath: string): Promise { + if (mediaType !== 'video' && mediaType !== 'audio') return undefined; + try { + return await ffprobeDuration(sourcePath); + } catch { + return undefined; + } +} + +async function probeAssetAudioQuality(mediaType: MediaTypeT, sourcePath?: string): Promise<{ + hasAudio?: boolean; + audioMeanVolumeDb?: number; + audioMaxVolumeDb?: number; + silentAudioRisk?: boolean; +}> { + if (!sourcePath || (mediaType !== 'video' && mediaType !== 'audio')) return {}; + let hasAudioStream: boolean; + try { + hasAudioStream = await ffprobeHasAudio(sourcePath); + } catch { + return {}; + } + if (!hasAudioStream) { + return { hasAudio: false, silentAudioRisk: false }; + } + try { + const stats = await probeAudioVolume(sourcePath); + return { + hasAudio: !stats.silentAudioRisk, + audioMeanVolumeDb: stats.meanVolumeDb, + audioMaxVolumeDb: stats.maxVolumeDb, + silentAudioRisk: stats.silentAudioRisk, + }; + } catch { + return { hasAudio: true }; + } +} + +type AudioQualityPatch = Awaited>; + +function shouldProbeAudioQuality(asset: { + mediaType: MediaTypeT; + sourcePath?: string; + hasAudio?: boolean; + audioMaxVolumeDb?: number; + silentAudioRisk?: boolean; +}): boolean { + if (!asset.sourcePath || (asset.mediaType !== 'video' && asset.mediaType !== 'audio')) return false; + if (hasContradictoryAudibleAudioMetadata(asset)) return true; + if (asset.hasAudio === false && asset.silentAudioRisk != null) return false; + return asset.audioMaxVolumeDb == null && asset.silentAudioRisk == null; +} + +function hasContradictoryAudibleAudioMetadata(asset: { + hasAudio?: boolean; + audioMaxVolumeDb?: number; + silentAudioRisk?: boolean; +}): boolean { + return ( + asset.audioMaxVolumeDb != null && + asset.audioMaxVolumeDb > SILENT_AUDIO_MAX_VOLUME_DB && + (asset.hasAudio === false || asset.silentAudioRisk === true) + ); +} + +async function ensureAssetAudioQuality( + store: Store, + asset: ReturnType[number], +): Promise[number]> { + if (!shouldProbeAudioQuality(asset)) return asset; + const patch = await probeAssetAudioQuality(asset.mediaType, asset.sourcePath); + if (!hasAudioQualityPatch(patch)) return asset; + return store.patchAsset(asset.id, patch) ?? { ...asset, ...patch }; +} + +async function ensureReferenceAudioQuality(asset: ReferenceAsset): Promise { + if (!shouldProbeAudioQuality(asset)) return asset; + const patch = await probeAssetAudioQuality(asset.mediaType, asset.sourcePath); + return hasAudioQualityPatch(patch) ? { ...asset, ...patch } : asset; +} + +function hasAudioQualityPatch(patch: AudioQualityPatch): boolean { + return ( + patch.hasAudio != null || + patch.audioMeanVolumeDb != null || + patch.audioMaxVolumeDb != null || + patch.silentAudioRisk != null + ); +} + +async function probeAssetAspectRatio(mediaType: MediaTypeT, sourcePath: string): Promise { + if (mediaType !== 'video' && mediaType !== 'image') return undefined; + try { + const metadata = await probeVideo(sourcePath); + return metadata.width > 0 && metadata.height > 0 ? `${metadata.width}:${metadata.height}` : undefined; + } catch { + return undefined; + } +} + function splitSellingPoints(value: unknown): string[] { if (Array.isArray(value)) return value.map(String).map((s) => s.trim()).filter(Boolean); return String(value ?? '') @@ -83,15 +335,994 @@ function splitSellingPoints(value: unknown): string[] { .filter(Boolean); } +function parseLearnScope(value: unknown): Partial | undefined { + if (!value || typeof value !== 'object') return undefined; + const raw = value as Partial>; + return { + storySkeleton: typeof raw.storySkeleton === 'boolean' ? raw.storySkeleton : undefined, + editingTechniques: typeof raw.editingTechniques === 'boolean' ? raw.editingTechniques : undefined, + packagingStyle: typeof raw.packagingStyle === 'boolean' ? raw.packagingStyle : undefined, + bgmSync: typeof raw.bgmSync === 'boolean' ? raw.bgmSync : undefined, + }; +} + +function referenceAssetsForMigration( + store: Store, + currentSample: Sample, + patterns: LearnedSamplePatternT[], +): ReferenceAsset[] { + const refs: ReferenceAsset[] = []; + if (currentSample.path) { + refs.push({ + id: `ref_current_${currentSample.id}`, + sourceRole: 'current_sample', + sourceSampleId: currentSample.id, + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: referenceVisualFunctionsFromSample(currentSample), + durationSec: currentSample.analysis?.metadata.durationSec, + hasAudio: currentSample.analysis?.metadata.hasAudio, + confidence: 0.48, + summary: `当前上传样例视频参考素材:${currentSample.filename}`, + sourcePath: currentSample.path, + }); + } + + const seen = new Set(refs.map((ref) => ref.sourceSampleId)); + for (const pattern of patterns) { + if (seen.has(pattern.sourceSampleId)) continue; + const sample = store.getSample(pattern.sourceSampleId); + const sourcePath = sample?.path ?? pattern.source.sourcePath; + if (!sourcePath) continue; + seen.add(pattern.sourceSampleId); + refs.push({ + id: `ref_pattern_${pattern.id}_${sample?.id ?? pattern.sourceSampleId}`, + sourceRole: 'learned_sample', + sourceSampleId: sample?.id ?? pattern.sourceSampleId, + patternId: pattern.id, + mediaType: 'video', + assetTags: ['b_roll'], + visualFunctions: referenceVisualFunctionsFromPattern(pattern), + durationSec: sample?.analysis?.metadata.durationSec ?? pattern.source.durationSec, + hasAudio: sample?.analysis?.metadata.hasAudio, + confidence: 0.42, + summary: `学习样例「${pattern.reusablePatternName}」参考素材:${sample?.filename ?? pattern.source.filename}。${pattern.summary}`, + sourcePath, + }); + } + return refs; +} + +function referenceVisualFunctionsFromSample(sample: Sample): VisualFunctionT[] { + const functions = sample.blueprint?.slots.flatMap((slot) => slot.requiredVisualFunctions ?? []) ?? []; + const roleFunctions = sample.blueprint?.scriptStructure.segments.flatMap((segment) => + visualFunctionsForSegmentRole(segment.role), + ) ?? []; + return uniqueValues([...functions, ...roleFunctions]); +} + +function referenceVisualFunctionsFromPattern(pattern: LearnedSamplePatternT): VisualFunctionT[] { + const storyFunctions = pattern.storySkeleton?.requiredStoryFunctions ?? []; + const slotFunctions = pattern.slotNeeds.flatMap((slot) => + slot.requiredAssetTypes.flatMap((tag) => visualFunctionsForAssetTag(tag)), + ); + const segmentFunctions = pattern.segments.flatMap((segment) => visualFunctionsForSegmentRole(segment.role)); + return uniqueValues([ + ...storyFunctions.flatMap(visualFunctionsForStoryFunction), + ...slotFunctions, + ...segmentFunctions, + ]); +} + +function visualFunctionsForStoryFunction(role: StoryFunctionT): VisualFunctionT[] { + if (role === 'opening_hook' || role === 'context') return ['establish_context', 'show_scale']; + if (role === 'character') return ['introduce_subject', 'show_emotion']; + if (role === 'action' || role === 'turn') return ['show_action', 'show_progression']; + if (role === 'detail' || role === 'contrast') return ['show_detail']; + if (role === 'proof' || role === 'payoff') return ['show_result']; + if (role === 'mood') return ['show_emotion']; + if (role === 'transition') return ['bridge_transition']; + if (role === 'cta') return ['call_to_action']; + return []; +} + +function visualFunctionsForAssetTag(tag: AssetTagT): VisualFunctionT[] { + if (tag === 'product_closeup') return ['show_detail']; + if (tag === 'usage_demo') return ['show_action', 'show_progression']; + if (tag === 'comparison') return ['show_result']; + if (tag === 'talking_head') return ['introduce_subject', 'show_emotion']; + if (tag === 'text_card') return ['call_to_action']; + return ['establish_context', 'bridge_transition']; +} + +function visualFunctionsForSegmentRole(role: SegmentRoleT): VisualFunctionT[] { + if (role === 'hook' || role === 'setup') return ['establish_context']; + if (role === 'develop') return ['show_action', 'show_detail']; + if (role === 'climax') return ['show_result']; + if (role === 'closing') return ['call_to_action']; + return []; +} + +async function detectProjectBeatGrid(opts: { + assets: Array<{ mediaType: MediaTypeT; hasAudio?: boolean; sourcePath?: string; durationSec?: number; isBgm?: boolean; silentAudioRisk?: boolean; audioMaxVolumeDb?: number }>; + referenceAssets: ReferenceAsset[]; + currentSampleAudio?: { sourcePath?: string; hasAudio?: boolean }; + durationSec: number; + cutDensity: string; + preferReferenceAudio?: boolean; + allowAutoReuse?: boolean; +}) { + const explicitAudioAsset = opts.assets.find( + (asset) => asset.isBgm && asset.mediaType === 'audio' && hasUsableAudio(asset) && asset.sourcePath, + ); + const explicitVideoWithAudio = opts.assets.find( + (asset) => asset.isBgm && asset.mediaType === 'video' && hasUsableAudio(asset) && asset.sourcePath, + ); + const explicitSourcePath = explicitAudioAsset?.sourcePath ?? explicitVideoWithAudio?.sourcePath; + if (!explicitSourcePath && opts.allowAutoReuse === false) return null; + + const audioAsset = opts.assets.find((asset) => asset.mediaType === 'audio' && hasUsableAudio(asset) && asset.sourcePath); + const videoWithAudio = opts.assets.find((asset) => asset.mediaType === 'video' && hasUsableAudio(asset) && asset.sourcePath); + const currentReferenceWithAudio = opts.referenceAssets.find( + (asset) => asset.sourceRole === 'current_sample' && asset.mediaType === 'video' && hasUsableAudio(asset) && asset.sourcePath, + ); + const referenceWithAudio = opts.referenceAssets.find((asset) => asset.mediaType === 'video' && hasUsableAudio(asset) && asset.sourcePath); + const currentSampleAudioPath = + opts.currentSampleAudio?.hasAudio === false ? undefined : opts.currentSampleAudio?.sourcePath; + const sourcePath = + explicitSourcePath ?? + (opts.preferReferenceAudio ? currentReferenceWithAudio?.sourcePath : undefined) ?? + audioAsset?.sourcePath ?? + videoWithAudio?.sourcePath ?? + currentSampleAudioPath ?? + referenceWithAudio?.sourcePath; + if (!sourcePath) return null; + try { + return await detectMusicStructure({ sourcePath, durationSec: opts.durationSec, cutDensity: opts.cutDensity }); + } catch { + return null; + } +} + +function explicitBgmCandidate( + assets: Array<{ mediaType: MediaTypeT; hasAudio?: boolean; durationSec?: number; isBgm?: boolean; silentAudioRisk?: boolean; audioMaxVolumeDb?: number }>, +) { + return assets.find( + (asset) => + asset.isBgm === true && + (asset.mediaType === 'audio' || asset.mediaType === 'video') && + hasUsableAudio(asset), + ); +} + +function explicitBgmCarouselDuration( + assets: Array<{ mediaType: MediaTypeT; hasAudio?: boolean; durationSec?: number; isBgm?: boolean }>, + explicitBgm: { durationSec?: number }, +): number { + const visualAssetCount = Math.max(1, assets.filter((asset) => asset.mediaType === 'video' || asset.mediaType === 'image').length); + const visualDriven = Math.max(8, Math.min(15, visualAssetCount * 1.1)); + const audioDriven = explicitBgm.durationSec ? Math.min(explicitBgm.durationSec, visualDriven) : visualDriven; + return Math.max(6, Math.min(20, Math.round(audioDriven * 1000) / 1000)); +} + +function buildMigrationControlsForRun( + rawControls: unknown, + analysis: SampleAnalysis | undefined, + music: DetectedMusicStructure | undefined, +): MigrationControlsT { + const parsed = MigrationControls.safeParse(rawControls).success + ? MigrationControls.parse(rawControls) + : MigrationControls.parse({}); + return MigrationControls.parse({ + locks: parsed.locks, + signals: { + transcriptCues: parsed.signals.transcriptCues.length + ? parsed.signals.transcriptCues + : analysis?.transcriptCues ?? [], + musicSections: parsed.signals.musicSections.length + ? parsed.signals.musicSections + : music?.sections ?? [], + clipScores: parsed.signals.clipScores, + safeCropHints: parsed.signals.safeCropHints, + }, + }); +} + +type MigrationJobInput = { + sampleId?: string; + topic?: string; + sellingPoints?: string[] | string; + durationSec?: number; + reuseUploadedBgm?: boolean; + migrationIntent?: string; + referenceClipMode?: string; + visualGapMode?: string; + templateAdaptationMode?: string; + migrationControls?: unknown; +}; + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +const MIGRATION_STEPS = [ + { name: 'context', label: '读取上下文' }, + { name: 'beat_grid', label: '分析 BGM / 节奏' }, + { name: 'director', label: 'Director / Expert 迁移' }, + { name: 'match', label: '素材匹配' }, + { name: 'gap', label: '缺口诊断' }, + { name: 'fill', label: '缺口补全' }, + { name: 'timeline', label: '时间线组装' }, + { name: 'qc', label: 'QC / 返工建议' }, +] as const; + +type MigrationStepName = (typeof MIGRATION_STEPS)[number]['name']; + +type MigrationStepController = { + start: (name: MigrationStepName, input?: unknown) => void; + succeed: (name: MigrationStepName, output?: unknown) => void; + fail: (name: MigrationStepName, error: unknown, output?: unknown) => void; +}; + +type MigrationArtifactSource = 'actual_step' | 'materialized_result'; + +function createMigrationStepController(store: Store, job: Job): MigrationStepController { + const steps = new Map( + MIGRATION_STEPS.map((def, index) => [ + def.name, + store.createJobStep(job.projectId, job.id, { + name: def.name, + label: def.label, + order: index + 1, + }), + ]), + ); + + const update = (name: MigrationStepName, patch: Parameters[1]) => { + const step = steps.get(name); + if (step) store.updateJobStep(step.id, patch); + }; + + return { + start(name, input) { + update(name, { status: 'running', input, startedAt: new Date().toISOString() }); + }, + succeed(name, output) { + const step = steps.get(name); + const current = step ? store.getJobStep(step.id) ?? step : undefined; + const completedAt = new Date().toISOString(); + const startedAt = current?.startedAt ?? current?.createdAt ?? completedAt; + update(name, { + status: 'succeeded', + output, + completedAt, + durationMs: Math.max(0, Date.parse(completedAt) - Date.parse(startedAt)), + }); + }, + fail(name, error, output) { + const step = steps.get(name); + const current = step ? store.getJobStep(step.id) ?? step : undefined; + const completedAt = new Date().toISOString(); + const startedAt = current?.startedAt ?? current?.createdAt ?? completedAt; + update(name, { + status: 'failed', + output, + error: errMsg(error), + completedAt, + durationMs: Math.max(0, Date.parse(completedAt) - Date.parse(startedAt)), + }); + }, + }; +} + +function stepArtifact(opts: { + kind: string; + summary: string; + source: MigrationArtifactSource; + metrics?: Record; + preview?: string[]; + data?: unknown; + warnings?: string[]; +}) { + return { + version: 1, + kind: opts.kind, + source: opts.source, + summary: opts.summary, + metrics: opts.metrics ?? {}, + preview: opts.preview ?? [], + warnings: opts.warnings ?? [], + data: opts.data, + }; +} + +function completeArtifactStep( + steps: MigrationStepController | undefined, + name: MigrationStepName, + input: unknown, + output: unknown, +) { + if (!steps) return; + steps.start(name, input); + steps.succeed(name, output); +} + +function summarizeMigrationRequest(projectId: string, input: MigrationJobInput) { + const sellingPointCount = splitSellingPoints(input.sellingPoints).length; + return stepArtifact({ + kind: 'migration.request', + source: 'actual_step', + summary: `生成主题「${input.topic ?? '新主题'}」,${sellingPointCount} 个卖点`, + metrics: { + sellingPointCount, + durationSec: parsePositiveNumber(input.durationSec), + reuseUploadedBgm: input.reuseUploadedBgm !== false, + }, + data: { + projectId, + sampleId: input.sampleId, + topic: input.topic ?? '新主题', + sellingPointCount, + durationSec: parsePositiveNumber(input.durationSec), + reuseUploadedBgm: input.reuseUploadedBgm !== false, + migrationIntent: input.migrationIntent, + visualGapMode: input.visualGapMode, + templateAdaptationMode: input.templateAdaptationMode, + }, + }); +} + +function summarizeAssetForTrace(asset: { + id: string; + mediaType: string; + assetTags?: string[]; + storyRoles?: string[]; + visualFunctions?: string[]; + durationSec?: number; + confidence?: number; + summary?: string; + hasAudio?: boolean; + isBgm?: boolean; + audioMeanVolumeDb?: number; + audioMaxVolumeDb?: number; + silentAudioRisk?: boolean; +}) { + return { + id: asset.id, + mediaType: asset.mediaType, + assetTags: asset.assetTags ?? [], + storyRoles: asset.storyRoles ?? [], + visualFunctions: asset.visualFunctions ?? [], + durationSec: asset.durationSec, + confidence: asset.confidence, + summary: asset.summary, + hasAudio: asset.hasAudio, + isBgm: asset.isBgm, + audioMeanVolumeDb: asset.audioMeanVolumeDb, + audioMaxVolumeDb: asset.audioMaxVolumeDb, + silentAudioRisk: asset.silentAudioRisk, + }; +} + +function summarizeReferenceForTrace(asset: ReferenceAsset) { + return { + id: asset.id, + sourceRole: asset.sourceRole, + mediaType: asset.mediaType, + durationSec: asset.durationSec, + hasAudio: asset.hasAudio, + summary: asset.summary, + }; +} + +function summarizeBeatGridForTrace( + beatGrid: MigrationPlan['timeline']['beatGrid'] | null | undefined, + music?: DetectedMusicStructure, +) { + const data = beatGrid + ? { + source: beatGrid.source, + bpm: beatGrid.bpm, + beatCount: beatGrid.beatsSec.length, + firstBeatSec: beatGrid.beatsSec[0], + lastBeatSec: beatGrid.beatsSec.at(-1), + confidence: beatGrid.confidence, + downbeatsSec: music?.downbeatsSec.slice(0, 24) ?? [], + phraseBoundariesSec: music?.phraseBoundariesSec.slice(0, 24) ?? [], + sections: music?.sections.slice(0, 12) ?? [], + dropTimeSec: music?.dropTimeSec, + } + : { source: 'none', beatCount: 0 }; + return stepArtifact({ + kind: 'migration.beat_grid', + source: 'actual_step', + summary: beatGrid + ? `${Math.round(beatGrid.bpm)} BPM · ${beatGrid.beatsSec.length} beats · ${beatGrid.source}` + : '未生成可用 BGM 节奏网格,后续使用估算或无音乐策略', + metrics: { + bpm: beatGrid?.bpm, + beatCount: beatGrid?.beatsSec.length ?? 0, + confidence: beatGrid?.confidence, + sectionCount: music?.sections.length ?? 0, + downbeatCount: music?.downbeatsSec.length ?? 0, + phraseBoundaryCount: music?.phraseBoundariesSec.length ?? 0, + dropTimeSec: music?.dropTimeSec, + }, + preview: [ + ...(beatGrid?.beatsSec.slice(0, 8).map((sec) => `${sec.toFixed(2)}s`) ?? []), + ...(music?.sections.slice(0, 4).map((section) => `${section.kind}:${section.startSec.toFixed(1)}-${section.endSec.toFixed(1)}s`) ?? []), + ], + data, + }); +} + +function summarizeDirectorForTrace(migration: MigrationPlan) { + const data = { + creativeBrief: { + selectedHookId: migration.creativeBrief.selectedHookId, + audience: migration.creativeBrief.audience, + tone: migration.creativeBrief.tone, + hookCandidates: migration.creativeBrief.hookCandidates.slice(0, 5), + }, + directorPlan: { + id: migration.directorPlan.id, + storyArc: migration.directorPlan.storyArc, + shotCount: migration.directorPlan.shots.length, + shots: migration.directorPlan.shots.slice(0, 20).map((shot) => ({ + shotId: shot.shotId, + storyBeat: shot.storyBeat, + storyFunction: shot.storyFunction, + visualRole: shot.visualRole, + copyMode: shot.copyMode, + screenTextIntent: shot.screenTextIntent, + })), + rationale: migration.directorPlan.rationale, + }, + }; + return stepArtifact({ + kind: 'migration.director', + source: 'actual_step', + summary: `选定 hook ${migration.creativeBrief.selectedHookId},规划 ${migration.directorPlan.shots.length} 个 shot`, + metrics: { + shotCount: migration.directorPlan.shots.length, + hookCandidateCount: migration.creativeBrief.hookCandidates.length, + }, + preview: migration.directorPlan.shots.slice(0, 6).map((shot) => `${shot.shotId}: ${shot.storyBeat}`), + data, + }); +} + +function summarizeMatchesForTrace(migration: MigrationPlan) { + const matched = migration.matches.filter((match) => match.status === 'matched').length; + const gaps = migration.matches.filter((match) => match.status === 'gap').length; + const data = { + matchCount: migration.matches.length, + matched, + gaps, + matches: migration.matches.slice(0, 50).map((match) => ({ + slotId: match.slotId, + status: match.status, + assetId: match.assetId, + score: match.score, + reason: match.reason, + })), + }; + return stepArtifact({ + kind: 'migration.match', + source: 'materialized_result', + summary: `匹配 ${matched}/${migration.matches.length} 个槽位,${gaps} 个缺口`, + metrics: { + matchCount: migration.matches.length, + matched, + gaps, + }, + preview: migration.matches.slice(0, 8).map((match) => `${match.slotId}: ${match.status}${match.assetId ? ` -> ${match.assetId}` : ''}`), + data, + }); +} + +function summarizeGapsForTrace(migration: MigrationPlan) { + const data = { + gapCount: migration.gaps.length, + visualGapPolicy: migration.visualGapPolicy, + visualCoverage: migration.visualCoverage, + gaps: migration.gaps.slice(0, 50).map((gap) => ({ + slotId: gap.slotId, + reason: gap.reason, + impactOnSegment: gap.impactOnSegment, + recommendedStrategies: gap.recommendedStrategies, + })), + }; + return stepArtifact({ + kind: 'migration.gap', + source: 'materialized_result', + summary: migration.gaps.length + ? `发现 ${migration.gaps.length} 个缺口,策略 ${migration.visualGapPolicy?.mode ?? 'unknown'}` + : '未发现结构槽位缺口', + metrics: { + gapCount: migration.gaps.length, + missingFunctionCount: migration.visualCoverage?.missingFunctions.length ?? 0, + weakFunctionCount: migration.visualCoverage?.weakFunctions.length ?? 0, + closeUpLikeShare: migration.visualCoverage?.closeUpLikeShare, + }, + preview: migration.gaps.slice(0, 8).map((gap) => `${gap.slotId}: ${gap.recommendedStrategies.join('/')}`), + data, + }); +} + +function summarizeFillsForTrace(migration: MigrationPlan) { + const stockClipCount = migration.fills.filter((fill) => fill.kind === 'stock_clip').length; + const data = { + fillCount: migration.fills.length, + stockClipCount, + fills: migration.fills.slice(0, 60).map((fill) => ({ + id: fill.id, + slotId: fill.slotId, + kind: fill.kind, + displayText: fill.displayText, + startSec: fill.startSec, + endSec: fill.endSec, + sourceKind: fill.source.startsWith('asset://') + ? 'asset' + : fill.source.startsWith('textcard://') + ? 'textcard' + : fill.source.startsWith('http') + ? 'remote' + : 'local_or_generated', + })), + }; + return stepArtifact({ + kind: 'migration.fill', + source: 'actual_step', + summary: `生成 / 增强 ${migration.fills.length} 个补全 artifact${stockClipCount ? `,含 ${stockClipCount} 个 stock clip` : ''}`, + metrics: { + fillCount: migration.fills.length, + stockClipCount, + textLikeFillCount: migration.fills.filter((fill) => fill.kind === 'text_card' || fill.kind === 'copy_completion').length, + }, + preview: migration.fills.slice(0, 8).map((fill) => `${fill.id}: ${fill.kind}`), + data, + }); +} + +function summarizeTimelineForTrace(migration: MigrationPlan) { + const data = { + durationSec: migration.timeline.durationSec, + itemCount: migration.timeline.items.length, + beatGrid: summarizeBeatGridForTrace(migration.timeline.beatGrid), + rhythmPlan: migration.timeline.rhythmPlan + ? { + strategy: migration.timeline.rhythmPlan.strategy, + matchedGlobalPattern: migration.timeline.rhythmPlan.matchedGlobalPattern, + pacingEnvelope: migration.timeline.rhythmPlan.pacingEnvelope, + } + : undefined, + items: migration.timeline.items.slice(0, 80).map((item) => ({ + id: item.id, + track: item.track, + startSec: item.startSec, + endSec: item.endSec, + shotRef: item.shotRef, + source: summarizeTimelineSource(item.source), + motionPreset: item.motionPreset, + transitionPreset: item.transitionPreset, + cardStylePreset: item.cardStylePreset, + })), + }; + return stepArtifact({ + kind: 'migration.timeline', + source: 'materialized_result', + summary: `${migration.timeline.durationSec.toFixed(1)}s 时间线,${migration.timeline.items.length} 个 item`, + metrics: { + durationSec: migration.timeline.durationSec, + itemCount: migration.timeline.items.length, + visualItemCount: migration.timeline.items.filter((item) => item.track !== 'audio').length, + audioItemCount: migration.timeline.items.filter((item) => item.track === 'audio').length, + }, + preview: migration.timeline.items.slice(0, 8).map((item) => `${item.track} ${item.startSec.toFixed(1)}-${item.endSec.toFixed(1)}s ${item.shotRef ?? item.id}`), + data, + }); +} + +function summarizeTimelineSource(source: MigrationPlan['timeline']['items'][number]['source']) { + if (source.kind === 'user_asset') return { kind: source.kind, assetId: source.assetId }; + if (source.kind === 'fill_artifact') return { kind: source.kind, fillArtifactId: source.fillArtifactId }; + if (source.kind === 'raw') return { kind: source.kind }; + return { kind: 'unknown' }; +} + +function summarizeQcForTrace(migration: MigrationPlan) { + const data = { + qcReport: migration.qcReport, + revisionPlan: migration.revisionPlan, + autoRevisionEvidence: migration.evidence.filter((evidence) => evidence.type === 'qc_auto_revision'), + }; + return stepArtifact({ + kind: 'migration.qc', + source: 'materialized_result', + summary: `QC ${migration.qcReport.verdict} · ${migration.qcReport.totalScore.toFixed(1)} 分 · ${migration.qcReport.issues.length} 个问题`, + metrics: { + totalScore: migration.qcReport.totalScore, + issueCount: migration.qcReport.issues.length, + revisionItemCount: migration.revisionPlan.items.length, + }, + preview: migration.qcReport.issues.slice(0, 6).map((issue) => `${issue.severity}: ${issue.description}`), + data, + warnings: migration.qcReport.issues.filter((issue) => issue.severity === 'high').map((issue) => issue.description), + }); +} + +async function executeMigration(opts: { + store: Store; + projectId: string; + input: MigrationJobInput; + migrate: MigrateFn; + stockFn: StockSearchFn; + onProgress?: (progress: { percent: number; stage: string; message?: string }) => void; + steps?: MigrationStepController; +}): Promise { + const { store, projectId, input, migrate, stockFn, onProgress, steps } = opts; + onProgress?.({ percent: 8, stage: 'load_context', message: '读取样例、素材和样例库…' }); + steps?.start('context', summarizeMigrationRequest(projectId, input)); + let sample: Sample | null = null; + let learnedPatterns: LearnedSamplePatternT[] = []; + let assets: ReturnType = []; + let referenceAssets: ReferenceAsset[] = []; + let fallbackDuration = 0; + let preferTemplateAudio = false; + let shouldDetectBeatGrid = false; + try { + const targetSample = store.getLatestSampleForStructure(projectId, input.sampleId); + if (!targetSample?.blueprint) throw new Error('需要先完成结构蓝图抽取'); + sample = targetSample; + const blueprint = targetSample.blueprint; + learnedPatterns = store.listLearnedPatterns(projectId); + assets = await Promise.all(store.listAssets(projectId).map((asset) => ensureAssetAudioQuality(store, asset))); + referenceAssets = await Promise.all( + referenceAssetsForMigration(store, sample, learnedPatterns).map(ensureReferenceAudioQuality), + ); + preferTemplateAudio = isCarouselTemplateProfile(sample.analysis?.templateProfile); + const explicitBgm = explicitBgmCandidate(assets); + fallbackDuration = + parsePositiveNumber(input.durationSec) ?? + (preferTemplateAudio && explicitBgm ? explicitBgmCarouselDuration(assets, explicitBgm) : undefined) ?? + (preferTemplateAudio ? sample.analysis?.templateProfile?.durationSec : undefined) ?? + Math.max(8, blueprint.rhythmStructure.avgShotSec * blueprint.scriptStructure.segments.length * 1.6); + shouldDetectBeatGrid = input.reuseUploadedBgm !== false || Boolean(explicitBgm); + const contextData = { + sampleId: sample.id, + blueprintId: blueprint.id, + segmentCount: blueprint.scriptStructure.segments.length, + slotCount: blueprint.slots.length, + assetCount: assets.length, + assets: assets.map(summarizeAssetForTrace), + learnedPatternCount: learnedPatterns.length, + learnedPatterns: learnedPatterns.slice(0, 20).map((pattern) => ({ + id: pattern.id, + name: pattern.name, + scope: pattern.scope, + tags: pattern.tags, + })), + referenceAssetCount: referenceAssets.length, + referenceAssets: referenceAssets.slice(0, 20).map(summarizeReferenceForTrace), + fallbackDuration, + preferTemplateAudio, + }; + steps?.succeed('context', stepArtifact({ + kind: 'migration.context', + source: 'actual_step', + summary: `样例 ${sample.id} · ${assets.length} 个素材 · ${learnedPatterns.length} 个样例 pattern`, + metrics: { + segmentCount: blueprint.scriptStructure.segments.length, + slotCount: blueprint.slots.length, + assetCount: assets.length, + learnedPatternCount: learnedPatterns.length, + referenceAssetCount: referenceAssets.length, + fallbackDuration, + }, + preview: assets.slice(0, 8).map((asset) => `${asset.mediaType}:${asset.summary}`), + data: contextData, + })); + } catch (e) { + steps?.fail('context', e); + throw e; + } + + onProgress?.({ percent: 22, stage: 'beat_grid', message: '分析 BGM / 节奏网格…' }); + if (!sample?.blueprint) throw new Error('需要先完成结构蓝图抽取'); + steps?.start('beat_grid', { + shouldDetectBeatGrid, + durationSec: fallbackDuration, + cutDensity: sample.blueprint.rhythmStructure.cutDensity, + audioCandidates: assets + .filter((asset) => asset.mediaType === 'audio' || asset.hasAudio) + .map(summarizeAssetForTrace), + }); + const detectedMusic = shouldDetectBeatGrid + ? await detectProjectBeatGrid({ + assets, + referenceAssets, + currentSampleAudio: { + sourcePath: sample.path, + hasAudio: sample.analysis?.metadata.hasAudio, + }, + durationSec: fallbackDuration, + cutDensity: sample.blueprint.rhythmStructure.cutDensity, + preferReferenceAudio: preferTemplateAudio, + allowAutoReuse: input.reuseUploadedBgm !== false, + }) + : null; + const detectedBeatGrid = detectedMusic?.beatGrid ?? null; + steps?.succeed('beat_grid', summarizeBeatGridForTrace(detectedBeatGrid, detectedMusic ?? undefined)); + + onProgress?.({ percent: 46, stage: 'director_migration', message: '执行 Director / Expert 迁移…' }); + steps?.start('director', { + topic: input.topic ?? '新主题', + sellingPoints: splitSellingPoints(input.sellingPoints), + assetCount: assets.length, + learnedPatternCount: learnedPatterns.length, + detectedBeatGrid: summarizeBeatGridForTrace(detectedBeatGrid, detectedMusic ?? undefined), + }); + let baseMigration: MigrationPlan; + try { + baseMigration = await migrate({ + projectId, + sampleId: sample.id, + blueprint: sample.blueprint, + sampleAnalysis: sample.analysis, + assets, + referenceAssets, + learnedPatterns, + topic: input.topic ?? '新主题', + sellingPoints: splitSellingPoints(input.sellingPoints), + durationSec: parsePositiveNumber(input.durationSec), + migrationIntent: MigrationIntent.safeParse(input.migrationIntent).success + ? MigrationIntent.parse(input.migrationIntent) + : undefined, + referenceClipMode: ReferenceClipMode.safeParse(input.referenceClipMode).success + ? ReferenceClipMode.parse(input.referenceClipMode) + : undefined, + visualGapMode: VisualGapMode.safeParse(input.visualGapMode).success + ? VisualGapMode.parse(input.visualGapMode) + : undefined, + templateAdaptationMode: TemplateAdaptationMode.safeParse(input.templateAdaptationMode).success + ? TemplateAdaptationMode.parse(input.templateAdaptationMode) + : undefined, + reuseUploadedBgm: input.reuseUploadedBgm !== false, + detectedBeatGrid: detectedBeatGrid ?? undefined, + migrationControls: buildMigrationControlsForRun(input.migrationControls, sample.analysis, detectedMusic ?? undefined), + }); + steps?.succeed('director', summarizeDirectorForTrace(baseMigration)); + } catch (e) { + steps?.fail('director', e); + throw e; + } + completeArtifactStep(steps, 'match', { + slotCount: sample.blueprint.slots.length, + assetCount: assets.length, + }, summarizeMatchesForTrace(baseMigration)); + completeArtifactStep(steps, 'gap', { + matchCount: baseMigration.matches.length, + visualGapMode: input.visualGapMode, + }, summarizeGapsForTrace(baseMigration)); + + onProgress?.({ percent: 78, stage: 'stock_fill', message: '检查素材缺口与可选 stock 补全…' }); + steps?.start('fill', { + initialFillCount: baseMigration.fills.length, + textLikeFillCount: baseMigration.fills.filter((fill) => fill.kind === 'text_card' || fill.kind === 'copy_completion').length, + }); + let migration: MigrationPlan; + try { + migration = await enhanceFillsWithStock(baseMigration, { + search: stockFn, + outDir: join(UPLOADS_ROOT, projectId, 'stock'), + blueprint: sample.blueprint, + }); + steps?.succeed('fill', summarizeFillsForTrace(migration)); + } catch (e) { + steps?.fail('fill', e, summarizeFillsForTrace(baseMigration)); + throw e; + } + completeArtifactStep(steps, 'timeline', { + fillCount: migration.fills.length, + }, summarizeTimelineForTrace(migration)); + completeArtifactStep(steps, 'qc', { + timelineItemCount: migration.timeline.items.length, + }, summarizeQcForTrace(migration)); + const record = store.saveMigration(migration); + onProgress?.({ percent: 100, stage: 'done', message: '迁移方案已生成' }); + return record; +} + +function createMigrationExecutor(opts: { + migrate: MigrateFn; + stockFn: StockSearchFn; +}): JobExecutor { + return async (store, job) => { + const steps = createMigrationStepController(store, job); + store.updateJob(job.id, { + status: 'running', + progress: { percent: 0, stage: 'start', message: '开始生成迁移方案…' }, + }); + const traced = await collectDebugTrace(() => + executeMigration({ + store, + projectId: job.projectId, + input: (job.input ?? {}) as MigrationJobInput, + migrate: opts.migrate, + stockFn: opts.stockFn, + steps, + onProgress: (progress) => store.updateJob(job.id, { progress }), + }), + ); + try { + if (!traced.ok) throw traced.error; + const migration = traced.result; + store.updateJob(job.id, { + status: 'succeeded', + result: { migrationId: migration.id, migration, debugTrace: traced.trace }, + progress: { percent: 100, stage: 'done', message: '迁移方案已生成' }, + }); + } catch (e) { + store.updateJob(job.id, { + status: 'failed', + error: errMsg(e), + result: { debugTrace: traced.trace }, + progress: undefined, + }); + } + }; +} + export async function buildApp(deps: AppDeps = {}): Promise { - const store = deps.store ?? new Store(); - const executors = { ...defaultExecutors, ...deps.executors }; + const store = deps.store ?? new Store({ + learnedPatternDbPath: resolve(process.cwd(), 'data/learned-patterns.json'), + learnedPatternSeedPath: resolve(process.cwd(), 'seeds/global-learned-patterns.seed.json'), + sqlitePath: resolve(process.cwd(), 'data/visionforge.sqlite'), + }); const migrate: MigrateFn = deps.migrate ?? ((input) => runExpertMigration(input)); + const tagFn: AssetTagFn = deps.tagFn ?? defaultTagFn; + const stockFn: StockSearchFn = deps.stockFn ?? searchAndDownloadStock; + const normalizeImageFn: ImageNormalizeFn = deps.normalizeImageFn ?? normalizeImageAsset; + const executors = { + ...defaultExecutors, + migration: createMigrationExecutor({ migrate, stockFn }), + ...deps.executors, + }; const app = Fastify({ logger: false }); await app.register(multipart, { limits: { fileSize: MAX_UPLOAD_BYTES } }); app.get('/health', async () => ({ ok: true })); + app.post('/api/sample-learning/samples', async (req, reply) => { + const file = await req.file(); + if (!file) { + reply.code(400); + return { error: '需要 multipart 字段 file' }; + } + + const sample = store.addSample(GLOBAL_LIBRARY_ID, '', file.filename ?? 'sample.mp4'); + const destPath = sampleUploadPath(GLOBAL_LIBRARY_ID, sample.id, file.filename ?? 'sample.mp4'); + store.patchSample(sample.id, { path: destPath }); + + try { + await saveMultipartFile(file, destPath); + } catch (e) { + if (e instanceof UploadError) { + reply.code(e.statusCode); + return { error: e.message }; + } + throw e; + } + + const analyzeJob = store.createJob(GLOBAL_LIBRARY_ID, 'analyze', { sampleId: sample.id }); + store.patchSample(sample.id, { latestAnalyzeJobId: analyzeJob.id }); + runJob(store, executors, analyzeJob); + + reply.code(201); + return { + sampleId: sample.id, + path: destPath, + filename: file.filename, + analyzeJobId: analyzeJob.id, + }; + }); + + app.post('/api/sample-learning/structure', async (req, reply) => { + const body = (req.body ?? {}) as { sampleId?: string; learnScope?: Partial }; + if (!body.sampleId) { + reply.code(400); + return { error: 'sampleId 必填' }; + } + const sample = store.getSample(body.sampleId); + if (!sample) { + reply.code(404); + return { error: 'sample not found' }; + } + if (!sample.analysis) { + reply.code(400); + return { error: '样例尚未完成 analyze,请等待或重试 analyze' }; + } + const job = store.createJob(GLOBAL_LIBRARY_ID, 'structure', { sampleId: sample.id }); + store.patchSample(sample.id, { latestStructureJobId: job.id }); + runJob(store, executors, job); + reply.code(202); + return { jobId: job.id, status: job.status, sampleId: sample.id }; + }); + + app.post('/api/sample-learning/draft', async (req, reply) => { + const body = (req.body ?? {}) as { sampleId?: string; learnScope?: unknown }; + if (!body.sampleId) { + reply.code(400); + return { error: 'sampleId 必填' }; + } + const sample = store.getSample(body.sampleId); + if (!sample) { + reply.code(404); + return { error: 'sample not found' }; + } + if (!sample.analysis || !sample.blueprint) { + reply.code(400); + return { error: '需要先完成 analyze 和 structure' }; + } + const draft = runSampleLearningAgent({ + scope: 'global', + learnScope: parseLearnScope(body.learnScope), + sample: { + id: sample.id, + filename: sample.filename, + analysis: sample.analysis, + blueprint: sample.blueprint, + }, + }); + return { draft }; + }); + + app.get('/api/sample-patterns', async () => ({ patterns: store.listGlobalLearnedPatterns() })); + + app.post('/api/sample-patterns', async (req, reply) => { + const parsed = LearnedSamplePattern.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') }; + } + const record = store.saveLearnedPattern({ ...parsed.data, scope: 'global', projectId: undefined }); + reply.code(201); + return { pattern: record }; + }); + + app.put('/api/sample-patterns/:patternId', async (req, reply) => { + const { patternId } = req.params as { patternId: string }; + if (!store.getLearnedPattern(patternId)) { + reply.code(404); + return { error: 'pattern not found' }; + } + const parsed = LearnedSamplePattern.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') }; + } + const record = store.saveLearnedPattern({ ...parsed.data, id: patternId, scope: 'global', projectId: undefined }); + return { pattern: record }; + }); + + app.delete('/api/sample-patterns', async () => { + const deletedCount = store.deleteGlobalLearnedPatterns(); + return { deletedCount }; + }); + + app.delete('/api/sample-patterns/:patternId', async (req, reply) => { + const { patternId } = req.params as { patternId: string }; + const pattern = store.getLearnedPattern(patternId); + if (!pattern || pattern.scope !== 'global') { + reply.code(404); + return { error: 'pattern not found' }; + } + store.deleteLearnedPattern(patternId); + reply.code(204); + return undefined; + }); + app.post('/api/projects', async (req, reply) => { const body = (req.body ?? {}) as { name?: string }; reply.code(201); @@ -126,6 +1357,71 @@ export async function buildApp(deps: AppDeps = {}): Promise { return { assets: store.listAssets(id) }; }); + app.patch('/api/projects/:id/assets/:assetId', async (req, reply) => { + const { id: projectId, assetId } = req.params as { id: string; assetId: string }; + if (!store.getProject(projectId)) { + reply.code(404); + return { error: 'project not found' }; + } + const asset = store.getAsset(assetId); + if (!asset || asset.projectId !== projectId) { + reply.code(404); + return { error: 'asset not found' }; + } + + const body = (req.body ?? {}) as { + assetTags?: unknown; + storyRoles?: unknown; + narrativeUse?: unknown; + visualFunctions?: unknown; + shotScale?: unknown; + visualMood?: unknown; + visualClusterId?: unknown; + summary?: unknown; + }; + const patch: Parameters[1] = {}; + + const assetTags = parseOptionalAssetTags(body.assetTags); + if (assetTags) patch.assetTags = assetTags; + if (body.storyRoles !== undefined) patch.storyRoles = parseStoryFunctions(body.storyRoles, []); + if (body.visualFunctions !== undefined) patch.visualFunctions = parseVisualFunctions(body.visualFunctions); + if (typeof body.narrativeUse === 'string') { + const parsed = StoryFunction.safeParse(body.narrativeUse); + if (parsed.success) patch.narrativeUse = parsed.data; + } + if (body.shotScale === null || body.shotScale === '') { + patch.shotScale = undefined; + } else if (typeof body.shotScale === 'string' && ['wide', 'medium', 'close', 'macro'].includes(body.shotScale)) { + patch.shotScale = body.shotScale as 'wide' | 'medium' | 'close' | 'macro'; + } + if (Array.isArray(body.visualMood)) { + patch.visualMood = uniqueValues(body.visualMood.filter((value): value is string => typeof value === 'string')); + } + if (typeof body.visualClusterId === 'string') patch.visualClusterId = body.visualClusterId.trim() || undefined; + if (typeof body.summary === 'string' && body.summary.trim()) patch.summary = body.summary.trim(); + + const updated = store.patchAsset(assetId, patch); + return { asset: updated }; + }); + + app.delete('/api/projects/:id/assets/:assetId', async (req, reply) => { + const { id: projectId, assetId } = req.params as { id: string; assetId: string }; + if (!store.getProject(projectId)) { + reply.code(404); + return { error: 'project not found' }; + } + const asset = store.deleteAsset(projectId, assetId); + if (!asset) { + reply.code(404); + return { error: 'asset not found' }; + } + if (asset.sourcePath) { + await rm(asset.sourcePath, { force: true }); + } + reply.code(204); + return undefined; + }); + app.post('/api/projects/:id/samples', async (req, reply) => { const { id: projectId } = req.params as { id: string }; if (!store.getProject(projectId)) { @@ -195,15 +1491,85 @@ export async function buildApp(deps: AppDeps = {}): Promise { } throw e; } + let sourcePath = destPath; + if (mediaType === 'image') { + try { + sourcePath = (await normalizeImageFn({ + sourcePath: destPath, + filename: file.filename ?? 'asset', + })).sourcePath; + } catch { + reply.code(415); + return { error: 'HEIC / HEIF 图片转换失败,请转成 JPG、PNG 或 WebP 后重试' }; + } + } + const explicitTags = parseAssetTags(fieldValue(file.fields, 'assetTags'), []); + const explicitStoryRoles = parseStoryFunctions(fieldValue(file.fields, 'storyRoles'), []); + const explicitVisualFunctions = parseVisualFunctions(fieldValue(file.fields, 'visualFunctions')); + const explicitNarrativeUse = StoryFunction.safeParse(fieldValue(file.fields, 'narrativeUse')).success + ? (fieldValue(file.fields, 'narrativeUse') as StoryFunctionT) + : undefined; + const explicitShotScale = fieldValue(file.fields, 'shotScale'); + const explicitSummary = fieldValue(file.fields, 'summary'); + const explicitVisualClusterId = fieldValue(file.fields, 'visualClusterId'); + const explicitAspectRatio = fieldValue(file.fields, 'aspectRatio'); + const isBgm = parseOptionalBoolean(fieldValue(file.fields, 'isBgm')) ?? false; + const durationSec = + parsePositiveNumber(fieldValue(file.fields, 'durationSec')) ?? + (await probeAssetDuration(mediaType, sourcePath)); + const audioQuality = await probeAssetAudioQuality(mediaType, sourcePath); + const aspectRatio = explicitAspectRatio ?? (await probeAssetAspectRatio(mediaType, sourcePath)); + + let assetTags = explicitTags.length ? explicitTags : defaultTagsFor(mediaType); + let storyRoles = explicitStoryRoles; + let narrativeUse = explicitNarrativeUse; + let visualFunctions = explicitVisualFunctions; + let shotScale = ['wide', 'medium', 'close', 'macro'].includes(explicitShotScale ?? '') + ? explicitShotScale + : undefined; + let visualMood: string[] = []; + let visualClusterId: string | undefined = explicitVisualClusterId; + let summary = explicitSummary ?? file.filename ?? '用户素材'; + let confidence = 1; + // 视觉素材始终尝试自动补语义;人工标签作为显式输入保留并与模型标签合并。 + if (mediaType === 'video' || mediaType === 'image') { + const auto = await tagFn({ path: sourcePath, mediaType, durationSec }); + if (auto) { + assetTags = explicitTags.length + ? uniqueValues([...explicitTags, ...auto.assetTags]) + : (auto.assetTags.length ? auto.assetTags : assetTags); + storyRoles = uniqueValues([...explicitStoryRoles, ...(auto.storyRoles ?? [])]); + narrativeUse = explicitNarrativeUse ?? auto.narrativeUse; + visualFunctions = uniqueValues([...explicitVisualFunctions, ...(auto.visualFunctions ?? [])]); + shotScale = shotScale ?? auto.shotScale; + visualMood = uniqueValues(auto.visualMood ?? []); + visualClusterId = explicitVisualClusterId ?? auto.visualClusterId; + summary = explicitSummary ?? auto.summary; + confidence = auto.confidence; + } + } + const saved = store.addAsset(projectId, { id: assetId, mediaType, - assetTags: parseAssetTags(fieldValue(file.fields, 'assetTags'), defaultTagsFor(mediaType)), - durationSec: parsePositiveNumber(fieldValue(file.fields, 'durationSec')) ?? 5, - confidence: 1, - summary: fieldValue(file.fields, 'summary') ?? file.filename ?? '用户素材', + assetTags, + storyRoles, + narrativeUse, + visualFunctions, + shotScale: shotScale as 'wide' | 'medium' | 'close' | 'macro' | undefined, + visualMood, + visualClusterId, + aspectRatio, + durationSec: durationSec ?? 5, + hasAudio: audioQuality.hasAudio, + audioMeanVolumeDb: audioQuality.audioMeanVolumeDb, + audioMaxVolumeDb: audioQuality.audioMaxVolumeDb, + silentAudioRisk: audioQuality.silentAudioRisk, + isBgm, + confidence, + summary, filename: file.filename, - sourcePath: destPath, + sourcePath, }); reply.code(201); return { asset: saved }; @@ -212,7 +1578,16 @@ export async function buildApp(deps: AppDeps = {}): Promise { const body = (req.body ?? {}) as { mediaType?: string; assetTags?: string[] | string; + storyRoles?: string[] | string; + narrativeUse?: string; + visualFunctions?: string[] | string; + shotScale?: string; + visualMood?: string[]; + visualClusterId?: string; + aspectRatio?: string; durationSec?: number; + hasAudio?: boolean; + isBgm?: boolean; confidence?: number; summary?: string; text?: string; @@ -220,13 +1595,24 @@ export async function buildApp(deps: AppDeps = {}): Promise { const parsedMediaType = MediaType.safeParse(body.mediaType ?? 'text'); if (!parsedMediaType.success) { reply.code(400); - return { error: 'mediaType 必须是 video / image / text' }; + return { error: 'mediaType 必须是 video / image / text / audio' }; } const mediaType = parsedMediaType.data; const asset = store.addAsset(projectId, { mediaType, assetTags: parseAssetTags(body.assetTags, defaultTagsFor(mediaType)), + storyRoles: parseStoryFunctions(body.storyRoles, []), + narrativeUse: StoryFunction.safeParse(body.narrativeUse).success ? body.narrativeUse as StoryFunctionT : undefined, + visualFunctions: parseVisualFunctions(body.visualFunctions), + shotScale: ['wide', 'medium', 'close', 'macro'].includes(body.shotScale ?? '') + ? body.shotScale as 'wide' | 'medium' | 'close' | 'macro' + : undefined, + visualMood: Array.isArray(body.visualMood) ? body.visualMood.map(String).filter(Boolean) : [], + visualClusterId: body.visualClusterId?.trim() || undefined, + aspectRatio: body.aspectRatio?.trim() || undefined, durationSec: parsePositiveNumber(body.durationSec) ?? (mediaType === 'text' ? 3 : 5), + hasAudio: parseOptionalBoolean(body.hasAudio), + isBgm: parseOptionalBoolean(body.isBgm) ?? false, confidence: body.confidence == null ? 1 : Math.max(0, Math.min(1, Number(body.confidence))), summary: body.summary?.trim() || body.text?.trim() || '文字素材', text: body.text, @@ -259,13 +1645,91 @@ export async function buildApp(deps: AppDeps = {}): Promise { return { jobId: job.id, status: job.status }; }); + app.post('/api/projects/:id/asr', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!store.getProject(id)) { + reply.code(404); + return { error: 'project not found' }; + } + const body = (req.body ?? {}) as { sampleId?: string; assetId?: string }; + if (body.sampleId && body.assetId) { + reply.code(400); + return { error: 'sampleId 和 assetId 只能传一个' }; + } + + const latestSample = () => store.listSamples(id).sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; + const targetSample = body.assetId ? undefined : body.sampleId ? store.getSample(body.sampleId) : latestSample(); + const targetAsset = body.assetId ? store.getAsset(body.assetId) : undefined; + + if (targetSample && targetSample.projectId !== id) { + reply.code(404); + return { error: 'sample not found' }; + } + if (targetAsset && targetAsset.projectId !== id) { + reply.code(404); + return { error: 'asset not found' }; + } + if (body.sampleId && !targetSample) { + reply.code(404); + return { error: 'sample not found' }; + } + if (body.assetId && !targetAsset) { + reply.code(404); + return { error: 'asset not found' }; + } + if (!targetSample && !targetAsset) { + reply.code(400); + return { error: '项目尚无可转写的样例或素材' }; + } + + const sourcePath = targetSample?.path || targetAsset?.sourcePath; + if (!sourcePath) { + reply.code(400); + return { error: '目标媒体缺少 sourcePath' }; + } + if (targetAsset && targetAsset.mediaType !== 'audio' && targetAsset.mediaType !== 'video') { + reply.code(400); + return { error: 'asset 必须是 audio 或 video' }; + } + const durationSec = + targetSample?.analysis?.metadata.durationSec ?? + targetAsset?.durationSec ?? + (await ffprobeDuration(sourcePath).catch(() => undefined)); + const result = await transcribeAudio({ sourcePath, durationSec }); + let persisted = false; + if (targetSample?.analysis && result.cues.length) { + const nextAnalysis: SampleAnalysis = { + ...targetSample.analysis, + transcriptCues: result.cues, + evidence: [ + ...targetSample.analysis.evidence, + { + type: 'asr', + detail: `ASR endpoint 手动转写 ${result.cues.length} 条 timestamp cues。`, + }, + ], + }; + store.patchSample(targetSample.id, { analysis: nextAnalysis }); + persisted = true; + } + return { + target: targetSample + ? { kind: 'sample', id: targetSample.id } + : { kind: 'asset', id: targetAsset!.id }, + source: result.source, + cues: result.cues, + error: result.error, + persisted, + }; + }); + app.post('/api/projects/:id/structure', async (req, reply) => { const { id } = req.params as { id: string }; if (!store.getProject(id)) { reply.code(404); return { error: 'project not found' }; } - const body = (req.body ?? {}) as { sampleId?: string }; + const body = (req.body ?? {}) as { sampleId?: string; learnScope?: Partial }; const sampleId = body.sampleId; if (sampleId && !store.getSample(sampleId)) { reply.code(404); @@ -304,44 +1768,246 @@ export async function buildApp(deps: AppDeps = {}): Promise { return { sampleId: sample.id, blueprint: sample.blueprint }; }); + // 用户在结构页编辑后的蓝图回存(替换 sample.blueprint,供后续迁移使用)。 + app.put('/api/projects/:id/structure', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!store.getProject(id)) { + reply.code(404); + return { error: 'project not found' }; + } + const body = (req.body ?? {}) as { sampleId?: string; blueprint?: unknown }; + const sample = store.getLatestSampleForStructure(id, body.sampleId); + if (!sample) { + reply.code(404); + return { error: 'sample not found' }; + } + const parsed = VideoStructureBlueprintSchema.safeParse(body.blueprint); + if (!parsed.success) { + reply.code(400); + return { error: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') }; + } + store.patchSample(sample.id, { blueprint: parsed.data }); + return { sampleId: sample.id, blueprint: parsed.data }; + }); + app.post('/api/projects/:id/migrate', async (req, reply) => { const { id } = req.params as { id: string }; if (!store.getProject(id)) { reply.code(404); return { error: 'project not found' }; } - const body = (req.body ?? {}) as { sampleId?: string; topic?: string; sellingPoints?: string[] | string }; + const body = (req.body ?? {}) as MigrationJobInput; + const sample = store.getLatestSampleForStructure(id, body.sampleId); + if (!sample?.blueprint) { + reply.code(400); + return { error: '需要先完成结构蓝图抽取' }; + } + const job = store.createJob(id, 'migration', body); + runJob(store, executors, job); + reply.code(202); + return { jobId: job.id, status: job.status, sampleId: sample.id }; + }); + + app.get('/api/projects/:id/migration', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!store.getProject(id)) { + reply.code(404); + return { error: 'project not found' }; + } + const migration = store.getLatestMigration(id); + if (!migration) { + reply.code(404); + return { error: 'migration not found' }; + } + return { migration }; + }); + + // 用户在迁移 / 输出页编辑后的迁移方案回存(脚本 / 分镜 / 导演层 / hook 等),渲染时即生效。 + app.put('/api/projects/:id/migration', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!store.getProject(id)) { + reply.code(404); + return { error: 'project not found' }; + } + const existing = store.getLatestMigration(id); + if (!existing) { + reply.code(404); + return { error: 'migration not found' }; + } + const parsed = MigrationPlanSchema.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') }; + } + // 强制沿用既有的归属与标识,避免客户端改写到别的项目 / 方案。 + const record = store.saveMigration({ ...parsed.data, id: existing.id, projectId: id }); + return { migration: record }; + }); + + // 编辑框「AI 协助」:基于字段、上下文与用户需求,给出可一键采用的中文文案候选。 + app.post('/api/assist', async (req, reply) => { + if (!getArkConfig()) { + reply.code(503); + return { error: '未配置火山方舟 ARK_API_KEY,AI 协助不可用' }; + } + const body = (req.body ?? {}) as { + field?: string; + instruction?: string; + current?: string; + context?: string; + count?: number; + }; + const field = String(body.field ?? '文案').slice(0, 100); + const instruction = String(body.instruction ?? '').trim().slice(0, 1000); + const current = String(body.current ?? '').trim().slice(0, 2000); + const context = String(body.context ?? '').trim().slice(0, 2000); + const count = Math.min(5, Math.max(1, Math.floor(Number(body.count)) || 3)); + const traced = await collectDebugTrace(() => + chatJson( + z.object({ suggestions: z.array(z.string().min(1)).min(1) }), + [ + { + role: 'system', + content: + '你是短视频创作助手,帮用户为视频结构 / 脚本 / 分镜 / 导演方案中的某个字段,生成可直接替换使用的简洁中文文案候选。不要输出解释或代码块,只输出 JSON。', + }, + { + role: 'user', + content: [ + `字段:${field}`, + context ? `上下文:${context}` : '', + current ? `当前内容:${current}` : '当前内容:(空)', + `用户需求:${instruction || '基于上下文给出更优写法'}`, + '', + `请给出 ${count} 条候选,每条可直接替换该字段、简洁不空话;如该字段是短语就给短语,是整句就给整句。`, + '只输出 {"suggestions": string[]}。', + ] + .filter(Boolean) + .join('\n'), + }, + ], + { temperature: 0.7, maxTokens: 1024, traceName: 'assist' }, + ), + ); + if (traced.ok) { + return { suggestions: traced.result.suggestions.slice(0, count), debugTrace: traced.trace }; + } + { + reply.code(502); + return { error: errMsg(traced.error), debugTrace: traced.trace }; + } + }); + + // 多版本:对蓝图套用预设变换 → 多个差异明确的迁移方案(规则版,无需 LLM/key)。 + app.post('/api/projects/:id/versions', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!store.getProject(id)) { + reply.code(404); + return { error: 'project not found' }; + } + const body = (req.body ?? {}) as { + sampleId?: string; + topic?: string; + sellingPoints?: string[] | string; + durationSec?: number; + }; const sample = store.getLatestSampleForStructure(id, body.sampleId); if (!sample?.blueprint) { reply.code(400); return { error: '需要先完成结构蓝图抽取' }; } - const migration = await migrate({ + const learnedPatterns = store.listLearnedPatterns(id); + const versions = generateVersions({ projectId: id, sampleId: sample.id, blueprint: sample.blueprint, assets: store.listAssets(id), + referenceAssets: referenceAssetsForMigration(store, sample, learnedPatterns), + learnedPatterns, topic: body.topic ?? '新主题', sellingPoints: splitSellingPoints(body.sellingPoints), - durationSec: parsePositiveNumber((body as { durationSec?: number }).durationSec) ?? 30, + durationSec: parsePositiveNumber(body.durationSec), }); - const record = store.saveMigration(migration); reply.code(201); - return { migration: record }; + return { versions }; }); - app.get('/api/projects/:id/migration', async (req, reply) => { + app.post('/api/projects/:id/sample-learning/draft', async (req, reply) => { const { id } = req.params as { id: string }; if (!store.getProject(id)) { reply.code(404); return { error: 'project not found' }; } - const migration = store.getLatestMigration(id); - if (!migration) { + const body = (req.body ?? {}) as { sampleId?: string; learnScope?: unknown }; + if (!body.sampleId) { + reply.code(400); + return { error: 'sampleId 必填' }; + } + const sample = store.getSample(body.sampleId); + if (!sample || sample.projectId !== id) { reply.code(404); - return { error: 'migration not found' }; + return { error: 'sample not found' }; } - return { migration }; + if (!sample.analysis || !sample.blueprint) { + reply.code(400); + return { error: '需要先完成 analyze 和 structure' }; + } + const draft = runSampleLearningAgent({ + projectId: id, + scope: 'project', + learnScope: parseLearnScope(body.learnScope), + sample: { + id: sample.id, + filename: sample.filename, + analysis: sample.analysis, + blueprint: sample.blueprint, + }, + }); + return { draft }; + }); + + app.get('/api/projects/:id/sample-patterns', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!store.getProject(id)) { + reply.code(404); + return { error: 'project not found' }; + } + return { patterns: store.listLearnedPatterns(id) }; + }); + + app.post('/api/projects/:id/sample-patterns', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!store.getProject(id)) { + reply.code(404); + return { error: 'project not found' }; + } + const parsed = LearnedSamplePattern.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') }; + } + const record = store.saveLearnedPattern({ ...parsed.data, scope: 'project', projectId: id }); + reply.code(201); + return { pattern: record }; + }); + + app.put('/api/projects/:id/sample-patterns/:patternId', async (req, reply) => { + const { id, patternId } = req.params as { id: string; patternId: string }; + if (!store.getProject(id)) { + reply.code(404); + return { error: 'project not found' }; + } + if (!store.getLearnedPattern(patternId)) { + reply.code(404); + return { error: 'pattern not found' }; + } + const parsed = LearnedSamplePattern.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') }; + } + const record = store.saveLearnedPattern({ ...parsed.data, id: patternId, scope: 'project', projectId: id }); + return { pattern: record }; }); app.post('/api/projects/:id/render', async (req, reply) => { @@ -350,7 +2016,11 @@ export async function buildApp(deps: AppDeps = {}): Promise { reply.code(404); return { error: 'project not found' }; } - const job = store.createJob(id, 'render'); + const body = (req.body ?? {}) as { renderer?: string; stabilizeVideo?: boolean | string }; + const job = store.createJob(id, 'render', { + renderer: parseRenderBackend(body.renderer) ?? 'remotion', + stabilizeVideo: parseStabilizationMode(body.stabilizeVideo) ?? 'auto', + }); runJob(store, executors, job); reply.code(202); return { jobId: job.id, status: job.status }; @@ -366,6 +2036,31 @@ export async function buildApp(deps: AppDeps = {}): Promise { return job; }); + app.get('/api/jobs/:id/steps', async (req, reply) => { + const { id } = req.params as { id: string }; + const job = store.getJob(id); + if (!job) { + reply.code(404); + return { error: 'job not found' }; + } + return { steps: store.listJobSteps(id) }; + }); + + app.get('/api/jobs/:id/debug', async (req, reply) => { + const { id } = req.params as { id: string }; + const job = store.getJob(id); + if (!job) { + reply.code(404); + return { error: 'job not found' }; + } + const result = job.result as { debugTrace?: unknown[] } | undefined; + return { + job, + steps: store.listJobSteps(id), + debugTrace: Array.isArray(result?.debugTrace) ? result.debugTrace : [], + }; + }); + app.get('/api/jobs/:id/output', async (req, reply) => { const { id } = req.params as { id: string }; const job = store.getJob(id); diff --git a/apps/api/src/server/imageAssets.ts b/apps/api/src/server/imageAssets.ts new file mode 100644 index 0000000..a268bd5 --- /dev/null +++ b/apps/api/src/server/imageAssets.ts @@ -0,0 +1,70 @@ +import { existsSync, statSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { basename, dirname, extname, join } from 'node:path'; +import { runFfmpeg } from '../render/ffmpeg'; + +export interface NormalizeImageInput { + sourcePath: string; + filename: string; +} + +export interface NormalizeImageResult { + sourcePath: string; + convertedFrom?: string; +} + +export type ImageNormalizeFn = (input: NormalizeImageInput) => Promise; + +const HEIC_EXT = new Set(['.heic', '.heif']); + +export function isHeicImage(filename: string): boolean { + return HEIC_EXT.has(extname(filename).toLowerCase()); +} + +export function convertedJpegPath(sourcePath: string): string { + const ext = extname(sourcePath); + return join(dirname(sourcePath), `${basename(sourcePath, ext)}.jpg`); +} + +export async function normalizeImageAsset(input: NormalizeImageInput): Promise { + if (!isHeicImage(input.filename)) return { sourcePath: input.sourcePath }; + + const outPath = convertedJpegPath(input.sourcePath); + try { + await convertWithSips(input.sourcePath, outPath); + assertUsableFile(outPath); + } catch { + await convertWithFfmpeg(input.sourcePath, outPath); + assertUsableFile(outPath); + } + + return { sourcePath: outPath, convertedFrom: input.sourcePath }; +} + +function convertWithSips(inputPath: string, outPath: string): Promise { + return runProcess('sips', ['-s', 'format', 'jpeg', inputPath, '--out', outPath]); +} + +async function convertWithFfmpeg(inputPath: string, outPath: string): Promise { + await runFfmpeg(['-i', inputPath, '-frames:v', '1', '-q:v', '2', outPath], 'heic-convert'); +} + +function assertUsableFile(path: string): void { + if (!existsSync(path) || statSync(path).size === 0) { + throw new Error(`converted image missing or empty: ${path}`); + } +} + +function runProcess(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const p = spawn(command, args); + let err = ''; + p.stderr.on('data', (d) => { + err += d.toString(); + }); + p.on('error', reject); + p.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`${command} exited ${code}:\n${err}`)), + ); + }); +} diff --git a/apps/api/src/server/jobs.ts b/apps/api/src/server/jobs.ts index 0ed4fa4..0174ebb 100644 --- a/apps/api/src/server/jobs.ts +++ b/apps/api/src/server/jobs.ts @@ -3,11 +3,15 @@ import { fileURLToPath } from 'node:url'; import { describeFrames } from '../agents/describeFrames'; import { runStructureAgent } from '../agents/structureAgent'; import { sampleTimeline } from '../core/mocks/sample-timeline'; -import type { MigrationPlan } from '../core/migration'; +import { hasUsableAudio, type MigrationPlan } from '../core/migration'; import type { SampleAnalysis } from '../core/sample'; -import type { TimelineSource } from '../core/timeline'; +import type { Timeline, TimelineSource } from '../core/timeline'; +import { collectDebugTrace } from '../llm/ark'; import { analyzeSample } from '../media/analyze'; -import { renderTimeline } from '../render/renderTimeline'; +import { SILENT_AUDIO_MAX_VOLUME_DB, probeAudioVolume } from '../media/audioQuality'; +import { ffprobeHasAudio } from '../render/ffmpeg'; +import { renderVideo, type RenderBackend } from '../render/renderVideo'; +import type { VideoStabilizationMode } from '../render/renderTimeline'; import type { Job, JobKind, Store } from './store'; function errMsg(e: unknown): string { @@ -35,6 +39,13 @@ export interface StructureJobInput { sampleId: string; } +export interface RenderJobInput { + renderer?: RenderBackend; + stabilizeVideo?: VideoStabilizationMode; +} + +type RenderAsset = ReturnType[number]; + /** 解析 analyze 输入:优先 sampleId,否则兼容旧版 sourcePath。 */ export function resolveAnalyzeInput( store: Store, @@ -56,25 +67,255 @@ export const renderExecutor: JobExecutor = async (store, job) => { store.updateJob(job.id, { status: 'running' }); try { const migration = store.getLatestMigration(job.projectId); - const timeline = migration?.timeline ?? sampleTimeline; + const audioGuard = migration + ? await guardTimelineAudioForRender(store, migration.timeline) + : { timeline: sampleTimeline, warnings: [] }; + const timeline = audioGuard.timeline; const subtitles = migration ? subtitlesFromMigration(migration) : demoSubtitles; - const result = await renderTimeline(timeline, { + const input = (job.input ?? {}) as RenderJobInput; + const rawResult = await renderVideo(timeline, { + renderer: input.renderer ?? 'remotion', outFile: resolve(OUT_DIR, `${job.id}.mp4`), subtitles, + stabilizeVideo: input.stabilizeVideo ?? 'auto', resolveAsset: migration ? resolveProjectAsset(store, migration) : undefined, }); + const result = await withFinalAudioWarnings({ + ...rawResult, + warnings: uniqueWarnings([...audioGuard.warnings, ...rawResult.warnings]), + }); store.updateJob(job.id, { status: 'succeeded', result }); } catch (e) { store.updateJob(job.id, { status: 'failed', error: errMsg(e) }); } }; -function subtitlesFromMigration(migration: MigrationPlan) { - return migration.script.map((line) => ({ - startSec: line.startSec, - endSec: line.endSec, - text: line.text, - })); +export async function guardTimelineAudioForRender( + store: Store, + timeline: Timeline, +): Promise<{ timeline: Timeline; warnings: string[] }> { + const audioItem = timeline.items.find((item) => item.track === 'audio'); + if (!audioItem) { + const fallback = await findProjectAudioFallback(store, timeline.projectId); + if (!fallback) { + return { + timeline, + warnings: ['时间线没有音频源,且项目中没有找到可用音源;输出将保持无声。'], + }; + } + return { + timeline: { + ...timeline, + items: [ + ...timeline.items, + { + id: 'it_audio_render_guard', + track: 'audio', + startSec: 0, + endSec: timeline.durationSec, + source: { kind: 'user_asset', assetId: fallback.asset.id }, + }, + ], + }, + warnings: [`时间线没有音频源,已改用${fallback.label}。`], + }; + } + if (await timelineSourceHasUsableAudio(store, audioItem.source)) return { timeline, warnings: [] }; + + const fallback = await findProjectAudioFallback(store, timeline.projectId, audioItem.source); + if (!fallback) { + return { + timeline, + warnings: ['时间线音频接近静音或不可用,且项目中没有找到可替代的可用音源。'], + }; + } + + return { + timeline: { + ...timeline, + items: timeline.items.map((item) => + item.id === audioItem.id + ? { ...item, source: { kind: 'user_asset', assetId: fallback.asset.id } } + : item, + ), + }, + warnings: [`时间线音频接近静音或不可用,已改用${fallback.label}。`], + }; +} + +async function timelineSourceHasUsableAudio(store: Store, source: TimelineSource): Promise { + if (source.kind === 'user_asset') { + const asset = store.getAsset(source.assetId); + if (!asset?.sourcePath) return false; + return hasUsableAudio(await ensureRenderAssetAudioQuality(store, asset)); + } + if (source.kind === 'raw') { + const quality = await probeRenderSourceAudioQuality(source.path); + return hasUsableAudio(quality); + } + return false; +} + +async function findProjectAudioFallback( + store: Store, + projectId: string, + currentSource?: TimelineSource, +): Promise<{ asset: RenderAsset; label: string } | null> { + for (const asset of orderedAudioCandidates(store.listAssets(projectId))) { + if (currentSource?.kind === 'user_asset' && currentSource.assetId === asset.id) continue; + const enriched = await ensureRenderAssetAudioQuality(store, asset); + if (!hasUsableAudio(enriched)) continue; + return { + asset: enriched, + label: renderAudioAssetLabel(enriched), + }; + } + return null; +} + +function orderedAudioCandidates(assets: RenderAsset[]): RenderAsset[] { + const ordered: RenderAsset[] = []; + const seen = new Set(); + const add = (asset: RenderAsset) => { + if (!asset.sourcePath || !isAudioCapableAsset(asset) || seen.has(asset.id)) return; + seen.add(asset.id); + ordered.push(asset); + }; + + assets.filter((asset) => asset.isBgm && asset.mediaType === 'audio').forEach(add); + assets.filter((asset) => asset.isBgm && asset.mediaType === 'video').forEach(add); + assets.filter((asset) => asset.mediaType === 'audio').forEach(add); + assets.filter((asset) => asset.mediaType === 'video').forEach(add); + return ordered; +} + +async function ensureRenderAssetAudioQuality(store: Store, asset: RenderAsset): Promise { + const sourcePath = asset.sourcePath; + if (!sourcePath || !shouldProbeRenderAssetAudioQuality(asset)) return asset; + const patch = await probeRenderSourceAudioQuality(sourcePath); + if (!hasRenderAudioQualityPatch(patch)) return asset; + return store.patchAsset(asset.id, patch) ?? { ...asset, ...patch }; +} + +function shouldProbeRenderAssetAudioQuality(asset: RenderAsset): boolean { + if (!asset.sourcePath || !isAudioCapableAsset(asset)) return false; + if (hasContradictoryAudibleAudioMetadata(asset)) return true; + if (asset.hasAudio === false && asset.silentAudioRisk != null) return false; + return asset.audioMaxVolumeDb == null && asset.silentAudioRisk == null; +} + +function hasContradictoryAudibleAudioMetadata(asset: RenderAsset): boolean { + return ( + asset.audioMaxVolumeDb != null && + asset.audioMaxVolumeDb > SILENT_AUDIO_MAX_VOLUME_DB && + (asset.hasAudio === false || asset.silentAudioRisk === true) + ); +} + +async function probeRenderSourceAudioQuality(sourcePath: string): Promise<{ + hasAudio?: boolean; + audioMeanVolumeDb?: number; + audioMaxVolumeDb?: number; + silentAudioRisk?: boolean; +}> { + try { + const hasAudioStream = await ffprobeHasAudio(sourcePath); + if (!hasAudioStream) return { hasAudio: false, silentAudioRisk: false }; + } catch { + return {}; + } + + try { + const stats = await probeAudioVolume(sourcePath); + return { + hasAudio: !stats.silentAudioRisk, + audioMeanVolumeDb: stats.meanVolumeDb, + audioMaxVolumeDb: stats.maxVolumeDb, + silentAudioRisk: stats.silentAudioRisk, + }; + } catch { + return { hasAudio: true }; + } +} + +function hasRenderAudioQualityPatch(patch: { + hasAudio?: boolean; + audioMeanVolumeDb?: number; + audioMaxVolumeDb?: number; + silentAudioRisk?: boolean; +}): boolean { + return ( + patch.hasAudio != null || + patch.audioMeanVolumeDb != null || + patch.audioMaxVolumeDb != null || + patch.silentAudioRisk != null + ); +} + +function isAudioCapableAsset(asset: RenderAsset): boolean { + return asset.mediaType === 'audio' || asset.mediaType === 'video'; +} + +function renderAudioAssetLabel(asset: RenderAsset): string { + const kind = asset.isBgm ? '显式 BGM' : asset.mediaType === 'audio' ? '上传音频' : '上传视频原声'; + const name = asset.summary || asset.filename || asset.id.slice(0, 8); + return `${kind}「${name}」`; +} + +async function withFinalAudioWarnings(result: T): Promise { + const warning = await finalAudioWarning(result.outFile); + if (!warning) return result; + return { ...result, warnings: uniqueWarnings([...result.warnings, warning]) }; +} + +async function finalAudioWarning(outFile: string): Promise { + try { + const hasAudioStream = await ffprobeHasAudio(outFile); + if (!hasAudioStream) return '输出文件没有音频流,请上传可用 BGM 或带声音的视频素材后重新渲染。'; + const stats = await probeAudioVolume(outFile); + if (!stats.silentAudioRisk) return undefined; + const max = stats.maxVolumeDb == null ? 'unknown' : `${stats.maxVolumeDb.toFixed(1)} dB`; + const mean = stats.meanVolumeDb == null ? 'unknown' : `${stats.meanVolumeDb.toFixed(1)} dB`; + return `输出音频仍接近静音(max=${max}, mean=${mean}),请检查 BGM 或素材原声是否可听。`; + } catch (e) { + return `输出音频检测失败:${errMsg(e)}`; + } +} + +function uniqueWarnings(warnings: string[]): string[] { + return [...new Set(warnings.filter(Boolean))]; +} + +export function subtitlesFromMigration(migration: MigrationPlan) { + const shotById = new Map(migration.directorPlan.shots.map((shot) => [shot.shotId, shot])); + return migration.storyboard.flatMap((item) => { + if (!item.shotId) return []; + const shot = shotById.get(item.shotId); + if (!shot || shot.copyRequired === false || !isBottomSubtitleMode(shot.copyMode)) return []; + if (isTextCardShot(migration, item.shotId)) return []; + const text = (item.screenText ?? '').trim(); + if (!text) return []; + return [{ + startSec: item.startSec, + endSec: item.endSec, + text, + }]; + }); +} + +function isBottomSubtitleMode(copyMode: string): boolean { + return copyMode === 'subtitle' || copyMode === 'caption' || copyMode === 'screen_text' || copyMode === 'title_card'; +} + +function isTextCardShot(migration: MigrationPlan, shotId: string): boolean { + return migration.timeline.items.some((item) => { + if (item.shotRef !== shotId) return false; + if (item.overlayText) return true; + const source = item.source; + if (source.kind !== 'fill_artifact') return false; + const fill = migration.fills.find((f) => f.id === source.fillArtifactId); + return Boolean(fill?.source.startsWith('textcard://')); + }); } function resolveProjectAsset(store: Store, migration: MigrationPlan) { @@ -83,12 +324,31 @@ function resolveProjectAsset(store: Store, migration: MigrationPlan) { return store.getAsset(source.assetId)?.sourcePath ?? null; } if (source.kind === 'fill_artifact') { - return migration.fills.find((f) => f.id === source.fillArtifactId)?.source ?? null; + const fill = migration.fills.find((f) => f.id === source.fillArtifactId); + const fillSource = fill?.source; + if (!fillSource) return null; + if (fillSource.startsWith('asset://')) { + const assetId = decodeURIComponent(fillSource.slice('asset://'.length)); + return store.getAsset(assetId)?.sourcePath ?? null; + } + if (fillSource.startsWith('textcard://')) { + return `textcard://${encodeURIComponent(fill.displayText ?? decodeLegacyTextCardSource(fillSource))}`; + } + return fillSource; } return source.path; }; } +function decodeLegacyTextCardSource(source: string): string { + const raw = source.slice('textcard://'.length); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + /** 分析 job:信号层解析,结果写入 Sample.analysis。 */ export const analyzeExecutor: JobExecutor = async (store, job) => { store.updateJob(job.id, { @@ -122,6 +382,7 @@ export const structureExecutor: JobExecutor = async (store, job) => { status: 'running', progress: { percent: 10, stage: 'describe_frames', message: '理解关键帧画面…' }, }); + let debugTrace: unknown[] | undefined; try { const { sampleId } = (job.input ?? {}) as StructureJobInput; if (!sampleId) throw new Error('structure job 缺少 sampleId'); @@ -129,27 +390,47 @@ export const structureExecutor: JobExecutor = async (store, job) => { if (!sample) throw new Error(`样例不存在: ${sampleId}`); if (!sample.analysis) throw new Error('需要先完成 analyze'); - const vlm = await describeFrames(sample.analysis.keyframes); - store.updateJob(job.id, { - progress: { percent: 55, stage: 'structure', message: '抽取结构蓝图…' }, - }); - const blueprint = await runStructureAgent({ - analysis: sample.analysis, - shotDescriptions: vlm.shotDescriptions, - contentSummary: vlm.summary, - genreHint: vlm.genreGuess, + const traced = await collectDebugTrace(async () => { + const vlm = await describeFrames(sample.analysis!.keyframes); + store.updateJob(job.id, { + progress: { percent: 55, stage: 'structure', message: '抽取结构蓝图…' }, + }); + const blueprint = await runStructureAgent({ + analysis: sample.analysis!, + transcript: transcriptTextFromAnalysis(sample.analysis!), + shotDescriptions: vlm.shotDescriptions, + contentSummary: vlm.summary, + genreHint: vlm.genreGuess, + }); + return { blueprint }; }); + debugTrace = traced.trace; + if (!traced.ok) throw traced.error; + const { blueprint } = traced.result; store.patchSample(sampleId, { blueprint, latestStructureJobId: job.id }); store.updateJob(job.id, { status: 'succeeded', - result: blueprint, + result: { blueprint, debugTrace: traced.trace }, progress: { percent: 100, stage: 'done', message: '结构抽取完成' }, }); } catch (e) { - store.updateJob(job.id, { status: 'failed', error: errMsg(e), progress: undefined }); + store.updateJob(job.id, { + status: 'failed', + error: errMsg(e), + result: debugTrace?.length ? { debugTrace } : undefined, + progress: undefined, + }); } }; +function transcriptTextFromAnalysis(analysis: SampleAnalysis): string | undefined { + if (!analysis.transcriptCues.length) return undefined; + return analysis.transcriptCues + .slice(0, 80) + .map((cue) => `${cue.startSec.toFixed(1)}-${cue.endSec.toFixed(1)}s: ${cue.text}`) + .join('\n'); +} + export const defaultExecutors: Partial> = { render: renderExecutor, analyze: analyzeExecutor, diff --git a/apps/api/src/server/paths.ts b/apps/api/src/server/paths.ts index 2781a55..548fe84 100644 --- a/apps/api/src/server/paths.ts +++ b/apps/api/src/server/paths.ts @@ -8,7 +8,22 @@ const here = dirname(fileURLToPath(import.meta.url)); export const UPLOADS_ROOT = resolve(here, '../../uploads'); export const ALLOWED_SAMPLE_EXT = new Set(['.mp4', '.mov']); -export const ALLOWED_ASSET_EXT = new Set(['.mp4', '.mov', '.jpg', '.jpeg', '.png', '.webp']); +export const ALLOWED_ASSET_EXT = new Set([ + '.mp4', + '.mov', + '.jpg', + '.jpeg', + '.png', + '.webp', + '.heic', + '.heif', + '.mp3', + '.wav', + '.m4a', + '.aac', + '.flac', + '.ogg', +]); /** 样例 / 素材上传上限;可用环境变量 MAX_UPLOAD_MB 覆盖(默认 200)。 */ function resolveMaxUploadBytes(): number { diff --git a/apps/api/src/server/store.ts b/apps/api/src/server/store.ts index abbe019..16ce62d 100644 --- a/apps/api/src/server/store.ts +++ b/apps/api/src/server/store.ts @@ -1,11 +1,20 @@ import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; import type { VideoStructureBlueprint } from '../core/blueprint'; import type { MigrationPlan } from '../core/migration'; import type { SampleAnalysis } from '../core/sample'; +import { + LearnedSamplePattern, + type LearnedSamplePattern as LearnedSamplePatternT, + type LearnedSamplePatternInput, + withInferredLearningQualityTags, +} from '../core/sampleLearning'; import type { TaggedAsset } from '../core/slot'; -export type JobKind = 'render' | 'analyze' | 'structure'; +export type JobKind = 'render' | 'analyze' | 'structure' | 'migration'; export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'; export interface Project { @@ -59,22 +68,81 @@ export interface Job { updatedAt: string; } +export type JobStepStatus = 'pending' | 'running' | 'succeeded' | 'failed'; + +export interface JobStep { + id: string; + projectId: string; + jobId: string; + name: string; + label: string; + order: number; + status: JobStepStatus; + input?: unknown; + output?: unknown; + error?: string; + startedAt?: string; + completedAt?: string; + durationMs?: number; + createdAt: string; + updatedAt: string; +} + export function isTerminal(status: JobStatus): boolean { return status === 'succeeded' || status === 'failed'; } -/** demo 阶段的内存态存储;后续可换 SQLite。 */ +export interface StoreOptions { + /** Optional local JSON database for learned sample patterns. */ + learnedPatternDbPath?: string; + /** Optional repository seed used only for a fresh local pattern store. */ + learnedPatternSeedPath?: string; + /** Optional SQLite database for durable project/job/migration state. */ + sqlitePath?: string; +} + +type SqliteStatement = { + run: (...params: unknown[]) => unknown; + all: (...params: unknown[]) => unknown[]; +}; + +type SqliteDatabase = { + exec: (sql: string) => void; + prepare: (sql: string) => SqliteStatement; +}; + +/** 内存态 API,按配置可把关键记录同步到本地 SQLite。 */ export class Store { readonly events = new EventEmitter(); + private learnedPatternDbPath?: string; + private learnedPatternSeedPath?: string; + private sqlitePath?: string; + private sqlite?: SqliteDatabase; private projects = new Map(); private samples = new Map(); private assets = new Map(); private migrations = new Map(); + private learnedPatterns = new Map(); private jobs = new Map(); + private jobSteps = new Map(); + + constructor(options: StoreOptions = {}) { + this.learnedPatternDbPath = options.learnedPatternDbPath; + this.learnedPatternSeedPath = options.learnedPatternSeedPath; + this.sqlitePath = options.sqlitePath; + const hasLocalJsonDb = Boolean(this.learnedPatternDbPath && existsSync(this.learnedPatternDbPath)); + const hasLocalSqliteDb = Boolean(this.sqlitePath && existsSync(this.sqlitePath)); + this.loadLearnedPatterns(); + this.initSqlite(); + const loadedSeed = this.loadSeedLearnedPatternsIfFresh({ hasLocalJsonDb, hasLocalSqliteDb }); + this.migrateLoadedLearnedPatternsToSqlite(); + if (loadedSeed) this.persistLearnedPatterns(); + } createProject(name: string): Project { const p: Project = { id: randomUUID(), name, createdAt: new Date().toISOString() }; this.projects.set(p.id, p); + this.persistRecord('project', p.id, p, p.id, p.createdAt); return p; } @@ -91,6 +159,7 @@ export class Store { createdAt: new Date().toISOString(), }; this.samples.set(s.id, s); + this.persistRecord('sample', s.id, s, projectId, s.createdAt); return s; } @@ -107,6 +176,7 @@ export class Store { if (!s) return null; const next = { ...s, ...patch }; this.samples.set(id, next); + this.persistRecord('sample', id, next, next.projectId, next.createdAt); return next; } @@ -121,6 +191,7 @@ export class Store { createdAt: new Date().toISOString(), }; this.assets.set(asset.id, asset); + this.persistRecord('asset', asset.id, asset, projectId, asset.createdAt); return asset; } @@ -128,13 +199,31 @@ export class Store { return this.assets.get(id) ?? null; } + patchAsset(id: string, patch: Partial>): ProjectAsset | null { + const asset = this.assets.get(id); + if (!asset) return null; + const next = { ...asset, ...patch }; + this.assets.set(id, next); + this.persistRecord('asset', id, next, next.projectId, next.createdAt); + return next; + } + listAssets(projectId: string): ProjectAsset[] { return [...this.assets.values()].filter((a) => a.projectId === projectId); } + deleteAsset(projectId: string, assetId: string): ProjectAsset | null { + const asset = this.assets.get(assetId); + if (!asset || asset.projectId !== projectId) return null; + this.assets.delete(assetId); + this.deleteRecord('asset', assetId); + return asset; + } + saveMigration(plan: MigrationPlan): MigrationRecord { const record: MigrationRecord = { ...plan, createdAt: new Date().toISOString() }; this.migrations.set(record.id, record); + this.persistRecord('migration', record.id, record, record.projectId, record.createdAt); return record; } @@ -150,6 +239,61 @@ export class Store { ); } + saveLearnedPattern(pattern: LearnedSamplePatternInput): LearnedSamplePatternT { + const existing = this.learnedPatterns.get(pattern.id); + const now = new Date().toISOString(); + const record = withInferredLearningQualityTags(LearnedSamplePattern.parse({ + ...pattern, + createdAt: existing?.createdAt ?? pattern.createdAt ?? now, + updatedAt: now, + })); + this.learnedPatterns.set(record.id, record); + this.persistRecord('learnedPattern', record.id, record, record.projectId, record.updatedAt); + this.persistLearnedPatterns(); + return record; + } + + getLearnedPattern(id: string): LearnedSamplePatternT | null { + const pattern = this.learnedPatterns.get(id); + return pattern ? withInferredLearningQualityTags(pattern) : null; + } + + deleteLearnedPattern(id: string): boolean { + const deleted = this.learnedPatterns.delete(id); + if (deleted) { + this.deleteRecord('learnedPattern', id); + this.persistLearnedPatterns(); + } + return deleted; + } + + deleteGlobalLearnedPatterns(): number { + const ids = [...this.learnedPatterns.values()] + .filter((pattern) => pattern.scope === 'global') + .map((pattern) => pattern.id); + if (ids.length === 0) return 0; + for (const id of ids) { + this.learnedPatterns.delete(id); + this.deleteRecord('learnedPattern', id); + } + this.persistLearnedPatterns(); + return ids.length; + } + + listLearnedPatterns(projectId: string): LearnedSamplePatternT[] { + return [...this.learnedPatterns.values()] + .filter((pattern) => pattern.scope === 'global' || pattern.projectId === projectId) + .map(withInferredLearningQualityTags) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + listGlobalLearnedPatterns(): LearnedSamplePatternT[] { + return [...this.learnedPatterns.values()] + .filter((pattern) => pattern.scope === 'global') + .map(withInferredLearningQualityTags) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + /** 取项目下最新已有蓝图的样例;无则取最新上传的样例。 */ getLatestSampleForStructure(projectId: string, sampleId?: string): Sample | null { if (sampleId) return this.getSample(sampleId); @@ -163,6 +307,7 @@ export class Store { const now = new Date().toISOString(); const job: Job = { id: randomUUID(), projectId, kind, status: 'queued', input, createdAt: now, updatedAt: now }; this.jobs.set(job.id, job); + this.persistRecord('job', job.id, job, projectId, job.updatedAt); this.events.emit('job', job); return job; } @@ -176,7 +321,324 @@ export class Store { if (!job) return null; const next: Job = { ...job, ...patch, updatedAt: new Date().toISOString() }; this.jobs.set(id, next); + this.persistRecord('job', id, next, next.projectId, next.updatedAt); this.events.emit('job', next); return next; } + + createJobStep( + projectId: string, + jobId: string, + data: { + name: string; + label: string; + order: number; + status?: JobStepStatus; + input?: unknown; + }, + ): JobStep { + const now = new Date().toISOString(); + const step: JobStep = { + id: randomUUID(), + projectId, + jobId, + name: data.name, + label: data.label, + order: data.order, + status: data.status ?? 'pending', + input: data.input, + createdAt: now, + updatedAt: now, + }; + this.jobSteps.set(step.id, step); + this.persistJobStep(step); + this.events.emit('jobStep', step); + return step; + } + + getJobStep(id: string): JobStep | null { + return this.jobSteps.get(id) ?? null; + } + + listJobSteps(jobId: string): JobStep[] { + return [...this.jobSteps.values()] + .filter((step) => step.jobId === jobId) + .sort((a, b) => a.order - b.order || a.createdAt.localeCompare(b.createdAt)); + } + + updateJobStep(id: string, patch: Partial>): JobStep | null { + const step = this.jobSteps.get(id); + if (!step) return null; + const next: JobStep = { ...step, ...patch, updatedAt: new Date().toISOString() }; + this.jobSteps.set(id, next); + this.persistJobStep(next); + this.events.emit('jobStep', next); + return next; + } + + private initSqlite() { + if (!this.sqlitePath) return; + mkdirSync(dirname(this.sqlitePath), { recursive: true }); + try { + const require = createRequire(import.meta.url); + const { DatabaseSync } = require('node:sqlite') as { + DatabaseSync: new (path: string) => SqliteDatabase; + }; + this.sqlite = new DatabaseSync(this.sqlitePath); + this.sqlite.exec(` + CREATE TABLE IF NOT EXISTS store_records ( + domain TEXT NOT NULL, + id TEXT NOT NULL, + project_id TEXT, + sort_key TEXT, + data TEXT NOT NULL, + PRIMARY KEY (domain, id) + ); + CREATE INDEX IF NOT EXISTS idx_store_records_domain_project + ON store_records(domain, project_id); + + CREATE TABLE IF NOT EXISTS job_steps ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + label TEXT NOT NULL, + step_order INTEGER NOT NULL, + status TEXT NOT NULL, + input TEXT, + output TEXT, + error TEXT, + started_at TEXT, + completed_at TEXT, + duration_ms INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_job_steps_job_order + ON job_steps(job_id, step_order); + CREATE INDEX IF NOT EXISTS idx_job_steps_project_status + ON job_steps(project_id, status); + `); + this.loadSqliteRecords(); + this.loadSqliteJobSteps(); + } catch { + this.sqlite = undefined; + } + } + + private loadSqliteRecords() { + if (!this.sqlite) return; + const rows = this.sqlite.prepare('SELECT domain, data FROM store_records').all() as Array<{ + domain: string; + data: string; + }>; + for (const row of rows) { + try { + const data = JSON.parse(row.data) as unknown; + if (row.domain === 'project') { + const project = data as Project; + this.projects.set(project.id, project); + } else if (row.domain === 'sample') { + const sample = data as Sample; + this.samples.set(sample.id, sample); + } else if (row.domain === 'asset') { + const asset = data as ProjectAsset; + this.assets.set(asset.id, asset); + } else if (row.domain === 'migration') { + const migration = data as MigrationRecord; + this.migrations.set(migration.id, migration); + } else if (row.domain === 'job') { + const job = data as Job; + this.jobs.set(job.id, job); + } else if (row.domain === 'learnedPattern') { + const parsed = LearnedSamplePattern.safeParse(data); + if (parsed.success) { + const pattern = withInferredLearningQualityTags(parsed.data); + this.learnedPatterns.set(pattern.id, pattern); + } + } + } catch { + // A corrupt row should not block app startup; later writes can replace it. + } + } + } + + private persistRecord( + domain: string, + id: string, + record: unknown, + projectId?: string, + sortKey?: string, + ) { + if (!this.sqlite) return; + this.sqlite + .prepare(` + INSERT INTO store_records(domain, id, project_id, sort_key, data) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(domain, id) DO UPDATE SET + project_id = excluded.project_id, + sort_key = excluded.sort_key, + data = excluded.data + `) + .run(domain, id, projectId ?? null, sortKey ?? null, JSON.stringify(record)); + } + + private deleteRecord(domain: string, id: string) { + if (!this.sqlite) return; + this.sqlite.prepare('DELETE FROM store_records WHERE domain = ? AND id = ?').run(domain, id); + } + + private loadSqliteJobSteps() { + if (!this.sqlite) return; + const rows = this.sqlite + .prepare(` + SELECT id, job_id, project_id, name, label, step_order, status, input, output, error, + started_at, completed_at, duration_ms, created_at, updated_at + FROM job_steps + `) + .all() as Array<{ + id: string; + job_id: string; + project_id: string; + name: string; + label: string; + step_order: number; + status: JobStepStatus; + input: string | null; + output: string | null; + error: string | null; + started_at: string | null; + completed_at: string | null; + duration_ms: number | null; + created_at: string; + updated_at: string; + }>; + for (const row of rows) { + const step: JobStep = { + id: row.id, + projectId: row.project_id, + jobId: row.job_id, + name: row.name, + label: row.label, + order: row.step_order, + status: row.status, + input: parseJsonCell(row.input), + output: parseJsonCell(row.output), + error: row.error ?? undefined, + startedAt: row.started_at ?? undefined, + completedAt: row.completed_at ?? undefined, + durationMs: row.duration_ms ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + this.jobSteps.set(step.id, step); + } + } + + private persistJobStep(step: JobStep) { + if (!this.sqlite) return; + this.sqlite + .prepare(` + INSERT INTO job_steps( + id, job_id, project_id, name, label, step_order, status, input, output, error, + started_at, completed_at, duration_ms, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + label = excluded.label, + status = excluded.status, + input = excluded.input, + output = excluded.output, + error = excluded.error, + started_at = excluded.started_at, + completed_at = excluded.completed_at, + duration_ms = excluded.duration_ms, + updated_at = excluded.updated_at + `) + .run( + step.id, + step.jobId, + step.projectId, + step.name, + step.label, + step.order, + step.status, + step.input === undefined ? null : JSON.stringify(step.input), + step.output === undefined ? null : JSON.stringify(step.output), + step.error ?? null, + step.startedAt ?? null, + step.completedAt ?? null, + step.durationMs ?? null, + step.createdAt, + step.updatedAt, + ); + } + + private migrateLoadedLearnedPatternsToSqlite() { + if (!this.sqlite) return; + for (const pattern of this.learnedPatterns.values()) { + this.persistRecord('learnedPattern', pattern.id, pattern, pattern.projectId, pattern.updatedAt); + } + } + + private loadLearnedPatterns() { + if (!this.learnedPatternDbPath || !existsSync(this.learnedPatternDbPath)) return; + try { + const raw = JSON.parse(readFileSync(this.learnedPatternDbPath, 'utf8')) as unknown; + const parsed = LearnedSamplePattern.array().safeParse(raw); + if (!parsed.success) return; + for (const pattern of parsed.data) { + this.learnedPatterns.set(pattern.id, pattern); + } + } catch { + // Keep startup resilient; invalid local DB should not break core demo flow. + } + } + + private loadSeedLearnedPatternsIfFresh(state: { + hasLocalJsonDb: boolean; + hasLocalSqliteDb: boolean; + }): boolean { + if ( + !this.learnedPatternSeedPath || + state.hasLocalJsonDb || + state.hasLocalSqliteDb || + this.learnedPatterns.size > 0 || + !existsSync(this.learnedPatternSeedPath) + ) { + return false; + } + try { + const raw = JSON.parse(readFileSync(this.learnedPatternSeedPath, 'utf8')) as unknown; + const parsed = LearnedSamplePattern.array().safeParse(raw); + if (!parsed.success) return false; + for (const pattern of parsed.data) { + if (pattern.scope === 'global') { + this.learnedPatterns.set(pattern.id, withInferredLearningQualityTags(pattern)); + } + } + return this.learnedPatterns.size > 0; + } catch { + // A missing or invalid repository seed should not block local app startup. + return false; + } + } + + private persistLearnedPatterns() { + if (!this.learnedPatternDbPath) return; + mkdirSync(dirname(this.learnedPatternDbPath), { recursive: true }); + writeFileSync( + this.learnedPatternDbPath, + `${JSON.stringify([...this.learnedPatterns.values()], null, 2)}\n`, + ); + } +} + +function parseJsonCell(value: string | null): unknown { + if (!value) return undefined; + try { + return JSON.parse(value); + } catch { + return undefined; + } } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 2019f65..283fb26 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -3,7 +3,8 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9ec6743..445c4c3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { HomePage } from './pages/HomePage'; import { MigratePage } from './pages/MigratePage'; import { OutputPage } from './pages/OutputPage'; import { SamplePage } from './pages/SamplePage'; +import { SampleLearningPage } from './pages/SampleLearningPage'; import { StructurePage } from './pages/StructurePage'; export default function App() { @@ -12,6 +13,7 @@ export default function App() { } /> + } /> }> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 8fb6d8e..7980339 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,11 +1,20 @@ import type { AssetTag, + JobDebug, Job, + JobStep, JobStatus, + LearnedSamplePattern, + MigrationControls, MigrationPlan, ProjectAsset, Project, + SampleLearningDraft, + ShotScale, + StoryFunction, + TimedTranscriptCue, UploadSampleResponse, + VisualFunction, VideoStructureBlueprint, } from './types'; @@ -43,9 +52,35 @@ export function uploadSampleWithProgress( file: File, onUploadProgress?: (percent: number) => void, ): Promise { + return uploadFileWithProgress( + `${API}/projects/${projectId}/samples`, + file, + undefined, + onUploadProgress, + ); +} + +export function uploadLibrarySampleWithProgress( + file: File, + onUploadProgress?: (percent: number) => void, +): Promise { + return uploadFileWithProgress( + `${API}/sample-learning/samples`, + file, + undefined, + onUploadProgress, + ); +} + +function uploadFileWithProgress( + endpoint: string, + file: File, + fields: Record | undefined, + onUploadProgress?: (percent: number) => void, +): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - xhr.open('POST', `${API}/projects/${projectId}/samples`); + xhr.open('POST', endpoint); xhr.upload.onprogress = (e) => { if (e.lengthComputable && onUploadProgress) { onUploadProgress(Math.round((e.loaded / e.total) * 100)); @@ -60,7 +95,7 @@ export function uploadSampleWithProgress( return; } if (xhr.status >= 200 && xhr.status < 300) { - resolve(body as UploadSampleResponse); + resolve(body as TResponse); return; } const msg = (body as { error?: string }).error ?? xhr.statusText; @@ -69,6 +104,9 @@ export function uploadSampleWithProgress( xhr.onerror = () => reject(new Error('网络错误,上传失败')); const form = new FormData(); form.append('file', file); + for (const [key, value] of Object.entries(fields ?? {})) { + if (value) form.append(key, value); + } xhr.send(form); }); } @@ -92,20 +130,93 @@ export async function createTextAsset( export async function uploadAsset( projectId: string, file: File, - payload: { assetTags: AssetTag[]; summary?: string; durationSec?: number }, + payload: { + assetTags?: AssetTag[]; + storyRoles?: StoryFunction[]; + visualFunctions?: VisualFunction[]; + shotScale?: ShotScale; + summary?: string; + durationSec?: number; + isBgm?: boolean; + }, +): Promise<{ asset: ProjectAsset }> { + return uploadAssetWithProgress(projectId, file, payload); +} + +export function uploadAssetWithProgress( + projectId: string, + file: File, + payload: { + assetTags?: AssetTag[]; + storyRoles?: StoryFunction[]; + visualFunctions?: VisualFunction[]; + shotScale?: ShotScale; + summary?: string; + durationSec?: number; + isBgm?: boolean; + }, + onUploadProgress?: (percent: number) => void, ): Promise<{ asset: ProjectAsset }> { - const form = new FormData(); - form.append('file', file); - form.append('assetTags', payload.assetTags.join(',')); - if (payload.summary) form.append('summary', payload.summary); - if (payload.durationSec) form.append('durationSec', String(payload.durationSec)); - return json(await fetch(`${API}/projects/${projectId}/assets`, { method: 'POST', body: form })); + return uploadFileWithProgress<{ asset: ProjectAsset }>( + `${API}/projects/${projectId}/assets`, + file, + { + ...(payload.assetTags ? { assetTags: payload.assetTags.join(',') } : {}), + ...(payload.storyRoles ? { storyRoles: payload.storyRoles.join(',') } : {}), + ...(payload.visualFunctions ? { visualFunctions: payload.visualFunctions.join(',') } : {}), + ...(payload.shotScale ? { shotScale: payload.shotScale } : {}), + ...(payload.summary ? { summary: payload.summary } : {}), + ...(payload.durationSec ? { durationSec: String(payload.durationSec) } : {}), + ...(payload.isBgm ? { isBgm: 'true' } : {}), + }, + onUploadProgress, + ); +} + +export async function deleteAsset(projectId: string, assetId: string): Promise { + const res = await fetch(`${API}/projects/${projectId}/assets/${assetId}`, { method: 'DELETE' }); + if (!res.ok) { + let msg = res.statusText; + try { + msg = ((await res.json()) as { error?: string }).error ?? msg; + } catch { + // 204 / empty error responses do not need parsing. + } + throw new Error(msg); + } +} + +export async function updateAsset( + projectId: string, + assetId: string, + payload: { + assetTags?: AssetTag[]; + storyRoles?: StoryFunction[]; + visualFunctions?: VisualFunction[]; + shotScale?: ShotScale | null; + summary?: string; + }, +): Promise<{ asset: ProjectAsset }> { + const res = await fetch(`${API}/projects/${projectId}/assets/${assetId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return json(res); } export async function getJob(jobId: string): Promise { return json(await fetch(`${API}/jobs/${jobId}`)); } +export async function getJobSteps(jobId: string): Promise<{ steps: JobStep[] }> { + return json(await fetch(`${API}/jobs/${jobId}/steps`)); +} + +export async function getJobDebug(jobId: string): Promise { + return json(await fetch(`${API}/jobs/${jobId}/debug`)); +} + export async function runStructure( projectId: string, sampleId?: string, @@ -118,6 +229,35 @@ export async function runStructure( return json(res); } +export async function runAsr( + projectId: string, + payload: { sampleId?: string; assetId?: string } = {}, +): Promise<{ + target: { kind: 'sample' | 'asset'; id: string }; + source: 'asr_endpoint' | 'not_configured' | 'failed' | 'no_audio'; + cues: TimedTranscriptCue[]; + error?: string; + persisted: boolean; +}> { + const res = await fetch(`${API}/projects/${projectId}/asr`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return json(res); +} + +export async function runLibraryStructure( + sampleId: string, +): Promise<{ jobId: string; status: JobStatus; sampleId: string }> { + const res = await fetch(`${API}/sample-learning/structure`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sampleId }), + }); + return json(res); +} + export async function getStructure( projectId: string, sampleId?: string, @@ -126,10 +266,51 @@ export async function getStructure( return json(await fetch(`${API}/projects/${projectId}/structure${q}`)); } +/** 回存用户编辑后的结构蓝图。 */ +export async function updateStructure( + projectId: string, + blueprint: VideoStructureBlueprint, + sampleId?: string, +): Promise<{ sampleId: string; blueprint: VideoStructureBlueprint }> { + const res = await fetch(`${API}/projects/${projectId}/structure`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sampleId, blueprint }), + }); + return json(res); +} + +/** 编辑框「AI 协助」:返回可一键采用的文案候选。 */ +export async function assist(payload: { + field: string; + instruction?: string; + current?: string; + context?: string; + count?: number; +}): Promise<{ suggestions: string[] }> { + const res = await fetch(`${API}/assist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return json(res); +} + export async function runMigrate( projectId: string, - payload: { sampleId?: string; topic: string; sellingPoints: string[] }, -): Promise<{ migration: MigrationPlan }> { + payload: { + sampleId?: string; + topic: string; + sellingPoints: string[]; + durationSec?: number; + reuseUploadedBgm?: boolean; + migrationIntent?: 'story_only' | 'editing_only' | 'story_and_editing'; + referenceClipMode?: 'learn_only' | 'allow_reference_clip'; + visualGapMode?: 'user_only' | 'smart_fill' | 'reference_bridge'; + templateAdaptationMode?: 'auto' | 'preserve_sample_frame' | 'portrait_safe'; + migrationControls?: MigrationControls; + }, +): Promise<{ jobId: string; status: JobStatus; sampleId: string }> { const res = await fetch(`${API}/projects/${projectId}/migrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -142,8 +323,87 @@ export async function getMigration(projectId: string): Promise<{ migration: Migr return json(await fetch(`${API}/projects/${projectId}/migration`)); } -export async function runRender(projectId: string): Promise<{ jobId: string; status: JobStatus }> { - const res = await fetch(`${API}/projects/${projectId}/render`, { method: 'POST' }); +/** 回存用户编辑后的迁移方案(脚本 / 分镜 / 导演层 / hook 等),渲染时即生效。 */ +export async function updateMigration( + projectId: string, + migration: MigrationPlan, +): Promise<{ migration: MigrationPlan }> { + const res = await fetch(`${API}/projects/${projectId}/migration`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(migration), + }); + return json(res); +} + +export async function createSampleLearningDraft( + sampleId: string, + learnScope?: { + storySkeleton?: boolean; + editingTechniques?: boolean; + packagingStyle?: boolean; + bgmSync?: boolean; + }, +): Promise<{ draft: SampleLearningDraft }> { + const res = await fetch(`${API}/sample-learning/draft`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sampleId, learnScope }), + }); + return json(res); +} + +export async function listSamplePatterns(): Promise<{ patterns: LearnedSamplePattern[] }> { + return json(await fetch(`${API}/sample-patterns`)); +} + +export async function listProjectSamplePatterns(projectId: string): Promise<{ patterns: LearnedSamplePattern[] }> { + return json(await fetch(`${API}/projects/${projectId}/sample-patterns`)); +} + +export async function saveSamplePattern( + pattern: LearnedSamplePattern, +): Promise<{ pattern: LearnedSamplePattern }> { + const res = await fetch(`${API}/sample-patterns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pattern), + }); + return json(res); +} + +export async function updateSamplePattern( + patternId: string, + pattern: LearnedSamplePattern, +): Promise<{ pattern: LearnedSamplePattern }> { + const res = await fetch(`${API}/sample-patterns/${patternId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pattern), + }); + return json(res); +} + +export async function deleteSamplePattern(patternId: string): Promise { + const res = await fetch(`${API}/sample-patterns/${patternId}`, { method: 'DELETE' }); + if (res.status === 204) return; + await json(res); +} + +export async function deleteAllSamplePatterns(): Promise<{ deletedCount: number }> { + const res = await fetch(`${API}/sample-patterns`, { method: 'DELETE' }); + return json(res); +} + +export async function runRender( + projectId: string, + payload: { renderer?: 'remotion' | 'ffmpeg'; stabilizeVideo?: boolean | 'auto' } = {}, +): Promise<{ jobId: string; status: JobStatus }> { + const res = await fetch(`${API}/projects/${projectId}/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); return json(res); } diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 7b58ccb..e06880d 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -1,6 +1,6 @@ export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'; -export type JobKind = 'render' | 'analyze' | 'structure'; -export type MediaType = 'video' | 'image' | 'text'; +export type JobKind = 'render' | 'analyze' | 'structure' | 'migration'; +export type MediaType = 'video' | 'image' | 'text' | 'audio'; export type AssetTag = | 'talking_head' | 'product_closeup' @@ -8,6 +8,32 @@ export type AssetTag = | 'comparison' | 'b_roll' | 'text_card'; +export type VisualFunction = + | 'establish_context' + | 'introduce_subject' + | 'show_action' + | 'show_detail' + | 'show_progression' + | 'show_result' + | 'show_emotion' + | 'show_scale' + | 'bridge_transition' + | 'call_to_action'; +export type StoryFunction = + | 'opening_hook' + | 'context' + | 'character' + | 'action' + | 'detail' + | 'contrast' + | 'proof' + | 'turn' + | 'payoff' + | 'cta' + | 'mood' + | 'transition'; +export type ShotScale = 'wide' | 'medium' | 'close' | 'macro'; +export type VisualGapMode = 'user_only' | 'smart_fill' | 'reference_bridge'; export interface JobProgress { percent: number; @@ -28,6 +54,56 @@ export interface Job { updatedAt: string; } +export type JobStepStatus = 'pending' | 'running' | 'succeeded' | 'failed'; + +export interface JobStepArtifact { + version: 1; + kind: string; + source?: 'actual_step' | 'materialized_result'; + summary: string; + metrics?: Record; + preview?: string[]; + warnings?: string[]; + data?: unknown; +} + +export interface JobStep { + id: string; + projectId: string; + jobId: string; + name: string; + label: string; + order: number; + status: JobStepStatus; + input?: unknown; + output?: unknown; + error?: string; + startedAt?: string; + completedAt?: string; + durationMs?: number; + createdAt: string; + updatedAt: string; +} + +export interface DebugTraceEntry { + id: string; + name: string; + attempt: number; + status: 'success' | 'invalid_json' | 'invalid_schema' | 'error'; + durationMs: number; + messages: unknown[]; + raw?: string; + parsed?: unknown; + validationError?: string; + error?: string; +} + +export interface JobDebug { + job: Job; + steps: JobStep[]; + debugTrace: DebugTraceEntry[]; +} + export interface Evidence { type: string; detail: string; @@ -40,12 +116,23 @@ export interface Segment { durationRatio: number; intent: string; copyPattern: string; + visualRole?: string; + shotScale?: string; + motionIntent?: string; + transitionIntent?: string; + captionStyle?: { + placement?: string; + density?: string; + bilingualLike?: boolean; + notes?: string; + }; } export interface StructureSlot { id: string; segmentRole: string; requiredAssetTypes: string[]; + requiredVisualFunctions?: VisualFunction[]; minDurationSec?: number; optional?: boolean; } @@ -54,11 +141,81 @@ export interface TaggedAsset { id: string; mediaType: MediaType; assetTags: AssetTag[]; + storyRoles?: StoryFunction[]; + narrativeUse?: StoryFunction; + visualFunctions?: VisualFunction[]; + shotScale?: ShotScale; + aspectRatio?: string; + visualMood?: string[]; + visualClusterId?: string; + qualityScore?: number; + highlightWindows?: Array<{ + startSec: number; + endSec: number; + score: number; + reason?: string; + }>; + safeCropPreset?: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'closeup'; durationSec?: number; + hasAudio?: boolean; + audioMeanVolumeDb?: number; + audioMaxVolumeDb?: number; + silentAudioRisk?: boolean; + isBgm?: boolean; confidence: number; summary: string; } +export interface MigrationLocks { + lockedPatternIds?: string[]; + lockedShotIds?: string[]; + shotAssetAssignments?: Record; +} + +export interface TimedTranscriptCue { + startSec: number; + endSec: number; + text: string; + confidence?: number; +} + +export interface MusicSection { + startSec: number; + endSec: number; + kind?: 'intro' | 'verse' | 'build' | 'drop' | 'chorus' | 'break' | 'outro' | 'unknown'; + confidence?: number; + downbeatsSec?: number[]; +} + +export interface ClipScore { + assetId: string; + score: number; + reasons?: string[]; + highlightWindows?: Array<{ + startSec: number; + endSec: number; + score: number; + reason?: string; + }>; +} + +export interface SafeCropHint { + assetId: string; + preset: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'closeup'; + reason?: 'face' | 'product' | 'action' | 'landscape' | 'manual' | 'unknown'; + confidence?: number; +} + +export interface MigrationControls { + locks?: MigrationLocks; + signals?: { + transcriptCues?: TimedTranscriptCue[]; + musicSections?: MusicSection[]; + clipScores?: ClipScore[]; + safeCropHints?: SafeCropHint[]; + }; +} + export interface ProjectAsset extends TaggedAsset { projectId: string; createdAt: string; @@ -67,6 +224,347 @@ export interface ProjectAsset extends TaggedAsset { text?: string; } +export interface MusicFingerprint { + hasAudio: boolean; + durationSec: number; + bpm?: number; + beatCount: number; + beatStability: number; + onsetDensity: number; + energyShape: 'steady' | 'front_loaded' | 'mid_peak' | 'late_peak' | 'rising' | 'unknown'; + peakAt: number; + downbeatsSec: number[]; + phraseBoundariesSec: number[]; + sections: MusicSection[]; + confidence: 'none' | 'low' | 'medium' | 'high'; + tags: string[]; +} + +export interface RhythmEditEvent { + eventType: 'cut' | 'caption' | 'title' | 'transition' | 'emphasis'; + timeSec: number; + relativeTime: number; + beatIndex?: number; + phraseIndex?: number; + nearestBeatSec?: number; + offsetMs: number; + segmentRole?: string; + storyFunction?: string; + strength: 'weak' | 'medium' | 'strong'; + description: string; +} + +export interface RhythmProfile { + id: string; + source: 'user_sample' | 'global_sample'; + durationSec: number; + music: MusicFingerprint; + shotPattern: { + cutDensity: string; + shotCount: number; + avgShotSec: number; + peakAt: number; + cutEveryBeats?: number; + phraseLengthBeats: number; + }; + events: RhythmEditEvent[]; + cutIntervalsSec: number[]; + captionStrategy: string; + strategySummary: string; +} + +export interface PacingEnvelopePhase { + role: 'setup' | 'accelerate' | 'hold' | 'climax' | 'payoff'; + startRatio: number; + endRatio: number; + avgShotSec: number; + cutDensity: 'low' | 'medium' | 'high' | 'burst'; + source: 'user_sample' | 'global_match' | 'blended'; + rationale: string; +} + +export interface PacingEnvelope { + id: string; + source: 'user_sample' | 'global_match' | 'blended'; + sampleWeight: number; + globalWeight: number; + phases: PacingEnvelopePhase[]; + qcAdjustmentPolicy: string; + rationale: string; +} + +export interface TemplateProfile { + id: string; + source: 'user_sample' | 'global_sample'; + durationSec: number; + sourceAspect: string; + targetCanvasAspect: string; + layoutPreset: + | 'full_bleed' + | 'cinematic_matte' + | 'camera_carousel' + | 'film_viewfinder_carousel' + | 'letterbox_frame' + | 'split_panel' + | 'unknown'; + frameStyle: { + backgroundColor: string; + matte: boolean; + roundedMask: boolean; + borderColor?: string; + labelStyle: 'none' | 'tiny_tech' | 'film_code' | 'camera_ui'; + viewport?: { + aspectRatio: string; + x: number; + y: number; + width: number; + height: number; + }; + }; + motionLanguage: { + internalMotionIntensity: 'low' | 'medium' | 'high'; + hasMaskReveals: boolean; + hasViewportSlides: boolean; + preferredMotionPreset: string; + preferredTransitionPreset: string; + notes: string[]; + }; + audioOnsets: Array<{ + timeSec: number; + relativeTime: number; + strength: 'weak' | 'medium' | 'strong'; + energyDb?: number; + }>; + events: Array<{ + kind: + | 'internal_motion' + | 'mask_reveal' + | 'viewport_slide' + | 'carousel_slide' + | 'asset_swap' + | 'caption_pop' + | 'audio_onset'; + timeSec: number; + relativeTime: number; + strength: 'weak' | 'medium' | 'strong'; + direction: 'left' | 'right' | 'up' | 'down' | 'in' | 'out' | 'mixed' | 'unknown'; + nearestOnsetSec?: number; + description: string; + }>; + strategySummary: string; + renderHints: string[]; +} + +export interface LearnedSamplePattern { + id: string; + scope: 'global' | 'project'; + projectId?: string; + sourceSampleId: string; + name: string; + summary: string; + videoGenre: string; + tags: string[]; + qualityTags?: { + patternDepth: 'full_story' | 'story_candidate' | 'thin_pattern' | 'template_or_editing_only'; + ctaType: + | 'none' + | 'platform_follow' + | 'search_account' + | 'tutorial_get' + | 'purchase' + | 'booking' + | 'trial' + | 'lead_capture' + | 'generic_next_action'; + visualBridgePolicy: { + use: 'allowed' | 'learn_only' | 'blocked'; + reasons: string[]; + }; + commercialUsefulness: 'strong' | 'medium' | 'weak' | 'not_recommended'; + recommendedUse: 'primary_story' | 'secondary_story' | 'editing_only' | 'template_only' | 'learn_only'; + warnings: string[]; + }; + learnScope?: { + storySkeleton: boolean; + editingTechniques: boolean; + packagingStyle: boolean; + bgmSync: boolean; + }; + reusablePatternName: string; + formula: string; + source: { + filename: string; + durationSec: number; + aspectRatio: string; + shotCount: number; + }; + segments: Array<{ + role: string; + label?: string; + durationRatio: number; + intent: string; + copyPattern: string; + watchingPurpose: string; + }>; + pacing: { + durationSec: number; + shotCount: number; + avgShotSec: number; + cutDensity: string; + peakAt: number; + beatHints: string[]; + }; + packaging?: { + subtitleDensity: string; + titleBarStyle: string; + stickerUsage: string; + transitionStyle: string; + coverStyle: string; + }; + storySkeleton?: { + arcType: string; + segmentRoles: string[]; + emotionalCurve: string[]; + hookStyle: string; + turnOrProofStyle: string; + payoffStyle: string; + requiredStoryFunctions: string[]; + bestForGenres: string[]; + assetRequirements: AssetTag[]; + }; + editingTechniques: Array<{ + id: string; + name: string; + triggerCondition: string; + appliesToStoryFunction: string[]; + appliesToAssetType: string[]; + appliesToVisualCluster: string[]; + motionPreset?: string; + transitionPreset?: string; + beatPlacement: string; + cardAnimationPreset?: string; + intensity: 'low' | 'medium' | 'high'; + avoidWhen: string[]; + requiredRenderer: 'remotion' | 'ffmpeg' | 'both'; + implementationNotes: string; + }>; + packagingPattern?: { + titleBarStyle?: string; + stickerUsage?: string; + coverStyle?: string; + overlayStyle: string; + cardAnimationPreset?: string; + implementationNotes: string; + }; + bgmSyncPattern?: { + beatPlacement: string; + syncStrategy: string; + confidence: 'none' | 'low' | 'medium' | 'high'; + limitations: string[]; + }; + musicFingerprint?: MusicFingerprint; + rhythmProfile?: RhythmProfile; + templateProfile?: TemplateProfile; + learnedDimensions?: { + scriptStructure: { + formula: string; + segmentCount: number; + segments: Array<{ + role: string; + label?: string; + durationRatio: number; + intent: string; + copyPattern: string; + watchingPurpose: string; + }>; + notes: string[]; + }; + shotRhythm: { + durationSec: number; + shotCount: number; + avgShotSec: number; + cutDensity: string; + peakAt: number; + beatHints: string[]; + rhythmNotes: string[]; + }; + subtitleStyle: { + density: string; + placement: string; + typography: string; + animation: string; + notes: string[]; + }; + visualPackaging: { + titleBarStyle?: string; + stickerUsage?: string; + coverStyle?: string; + overlayStyle: string; + notes: string[]; + }; + transitions: { + style: string; + frequency: string; + notableTransitions: string[]; + executableTechniques?: Array<{ + id: string; + name: string; + triggerCondition: string; + appliesToStoryFunctions: string[]; + requiredRenderer: 'remotion' | 'ffmpeg' | 'both'; + motionPreset?: string; + transitionPreset?: string; + cardAnimationPreset?: string; + implementationNotes: string; + }>; + }; + bgmSync: { + hasAudio: boolean; + beatHints: string[]; + syncStrategy: string; + confidence: 'none' | 'low' | 'medium' | 'high'; + limitations: string[]; + }; + }; + slotNeeds: Array<{ + slotId: string; + segmentRole: string; + requiredAssetTypes: AssetTag[]; + minDurationSec?: number; + optional: boolean; + }>; + evidence: Evidence[]; + rationale: string; + createdAt: string; + updatedAt: string; +} + +export interface SampleLearningDraft { + id: string; + scope: 'global' | 'project'; + projectId?: string; + sampleId: string; + status: 'draft'; + learnedThings: { + summary: string; + reusablePatternName: string; + formula: string; + keyTakeaways: string[]; + storageNotes: string[]; + risks: string[]; + rejectedTechniques: Array<{ + name: string; + reason: string; + userMessage: string; + }>; + recommendation: { + suggestedMode: 'storySkeleton' | 'editingTechniques' | 'both'; + reasons: string[]; + }; + }; + dbData: LearnedSamplePattern; +} + export interface SlotMatch { slotId: string; assetId?: string; @@ -87,6 +585,8 @@ export interface FillArtifact { slotId: string; kind: string; source: string; + displayText?: string; + debugLabel?: string; track: 'video' | 'text' | 'overlay'; startSec: number; endSec: number; @@ -97,15 +597,22 @@ export interface ScriptLine { startSec: number; endSec: number; text: string; + voiceoverScript?: string; + screenText?: string; + cardCopy?: string; } export interface StoryboardItem { + shotId?: string; segmentRole: string; slotId?: string; startSec: number; endSec: number; visual: string; copy: string; + voiceoverScript?: string; + screenText?: string; + cardCopy?: string; } export interface TimelineItem { @@ -113,7 +620,29 @@ export interface TimelineItem { track: string; startSec: number; endSec: number; + sourceInSec?: number; + sourceOutSec?: number; + motionPreset?: string; + transitionPreset?: string; + cropPreset?: string; + framePolicy?: string; + cardStylePreset?: string; + cardAnimationPreset?: string; + overlayText?: string; slotRef?: string; + shotRef?: string; + rhythmAnchor?: { + source: 'user_sample' | 'global_match' | 'target_music' | 'estimated'; + beatIndex?: number; + phraseIndex?: number; + preferredTimeSec?: number; + actualTimeSec?: number; + nearestBeatSec?: number; + offsetMs: number; + toleranceMs: number; + lockStrength: 'hard' | 'soft'; + rationale: string; + }; source: { kind: string; assetId?: string; fillArtifactId?: string; path?: string }; } @@ -121,9 +650,71 @@ export interface Timeline { id: string; projectId: string; durationSec: number; + beatGrid?: { + bpm: number; + offsetSec: number; + beatsSec: number[]; + source: 'estimated' | 'sample_hint' | 'detected'; + confidence: 'low' | 'medium' | 'high'; + rationale: string; + }; + rhythmPlan?: { + strategy: 'same_bgm_direct' | 'beat_index_mapping' | 'global_music_match' | 'target_music_regenerate' | 'sample_structure_estimated'; + sourcePriority: Array<'user_sample' | 'global_match' | 'target_music' | 'estimated'>; + targetMusic: MusicFingerprint; + sampleProfile?: RhythmProfile; + matchedGlobalPattern?: { + patternId: string; + name: string; + score: number; + bpm?: number; + reason: string; + }; + pacingEnvelope?: PacingEnvelope; + events: Array; + qualityTargets: { + cutToBeatToleranceMs: number; + highEnergyMaxShotSec: number; + staticMaxSec: number; + }; + rationale: string; + }; + templateProfile?: TemplateProfile; items: TimelineItem[]; } +export interface VisualCoverageReport { + requiredFunctions: VisualFunction[]; + coveredFunctions: VisualFunction[]; + missingFunctions: VisualFunction[]; + weakFunctions: VisualFunction[]; + detailItems: Array<{ + visualFunction: VisualFunction; + requiredShots: number; + coveredAssets: number; + status: 'covered' | 'weak' | 'missing'; + note: string; + }>; + closeUpLikeShare: number; + aspectMismatch: boolean; + referenceClipAllowed: boolean; + recommendation: string; +} + +export interface VisualGapPolicy { + mode: VisualGapMode; + adaptationMode: 'faithful_transfer' | 'adapted_transfer' | 'packaged_story' | 'needs_capture'; + referenceClipUse: 'none' | 'bridge_only'; + maxReferenceShare: number; + maxReferenceClipSec: number; + rationale: string; +} + export interface Decision { chosen: string; alternatives: string[]; @@ -131,6 +722,184 @@ export interface Decision { reason: string; } +export interface CreativeBrief { + topic: string; + audience: string; + platform: string; + goal: string; + durationSec: number; + corePromise: string; + tone: string; + hookCandidates: Array<{ + id: string; + type: string; + text: string; + score: { + clarity: number; + curiosity: number; + audienceFit: number; + visualPotential: number; + total: number; + }; + rationale: string; + }>; + selectedHookId: string; + hookRationale: string; + evidence: Evidence[]; + rationale: string; +} + +export interface DirectorPlan { + id: string; + creativeBrief: CreativeBrief; + selectedPatternId?: string; + storyArc: { + opening: string; + setup?: string; + progression: string; + turn?: string; + payoff: string; + emotionalCurve?: string[]; + }; + assetBudget: { + reusableAssetCount: number; + usableVisualSec: number; + repeatedVisualRisk: 'low' | 'medium' | 'high'; + notes: string[]; + }; + editConstraints: { + durationSec: number; + targetShotSec: number; + cutDensity: string; + subtitleDensity?: string; + cardStylePreset: string; + cardAnimationPreset: string; + maxContinuousAssetSec: number; + }; + fillPolicy: { + preferredOrder: string[]; + rationale: string; + }; + shots: Array<{ + shotId: string; + segmentRole: string; + slotId?: string; + startSec: number; + endSec: number; + purpose: string; + storyBeat: string; + visualDirection: string; + visualRole?: string; + shotScale?: string; + visualFunctions?: VisualFunction[]; + communicationIntent?: string; + copyMode?: 'none' | 'subtitle' | 'caption' | 'voiceover' | 'screen_text' | 'title_card'; + copyRequired?: boolean; + copyPurpose?: string; + screenTextIntent: string; + assetNeed: AssetTag[]; + preferredAssetIds: string[]; + fallbackStrategies: string[]; + motionPreset: string; + cropPreset: string; + transitionPreset: string; + mustShow: string; + }>; + evidence: Evidence[]; + rationale: string; +} + +export interface BeatMap { + durationSec: number; + beats: Array<{ + id: string; + segmentRole: string; + startSec: number; + endSec: number; + purpose: string; + visualChange: string; + screenText: string; + audioCue?: string; + }>; + rationale: string; +} + +export interface ShotList { + shots: Array<{ + shotId: string; + beatId: string; + segmentRole: string; + purpose: string; + subject: string; + action: string; + composition: string; + motion: string; + assetNeed: AssetTag[]; + sourcePreference: string; + }>; + rationale: string; +} + +export interface AssetPlan { + items: Array<{ + shotId: string; + slotId?: string; + requiredTags: AssetTag[]; + matchedAssetId?: string; + gapId?: string; + fillArtifactId?: string; + fillStrategy?: string; + reason: string; + }>; + rationale: string; +} + +export interface EditDecisionList { + decisions: Array<{ + id: string; + beatId?: string; + shotId?: string; + startSec: number; + endSec: number; + sourceKind: string; + sourceRef?: string; + subtitle: string; + overlay?: string; + transition?: string; + reason: string; + }>; + rationale: string; +} + +export interface QCReport { + totalScore: number; + verdict: 'pass' | 'conditional_pass' | 'fail'; + scores: Record; + issues: Array<{ + id: string; + severity: 'low' | 'medium' | 'high'; + module: string; + description: string; + suggestion: string; + targetAgent?: string; + artifactRef?: string; + timeRange?: string; + }>; + evidenceRefs: string[]; + rationale: string; +} + +export interface RevisionPlan { + items: Array<{ + issueId: string; + targetAgent: string; + targetArtifact: string; + requiredChange: string; + acceptanceCriteria: string; + }>; + rationale: string; +} + export interface MigrationPlan { id: string; projectId: string; @@ -143,6 +912,16 @@ export interface MigrationPlan { script: ScriptLine[]; storyboard: StoryboardItem[]; timeline: Timeline; + visualCoverage?: VisualCoverageReport; + visualGapPolicy?: VisualGapPolicy; + directorPlan?: DirectorPlan; + creativeBrief?: CreativeBrief; + beatMap?: BeatMap; + shotList?: ShotList; + assetPlan?: AssetPlan; + editDecisionList?: EditDecisionList; + qcReport?: QCReport; + revisionPlan?: RevisionPlan; evidence: Evidence[]; decisions: Decision[]; rationale: string; @@ -188,6 +967,8 @@ export interface SampleAnalysis { metadata: SampleMetadata; scenes: { index: number; atSec: number }[]; shotCount: number; + templateProfile?: TemplateProfile; + transcriptCues: TimedTranscriptCue[]; keyframes: { atSec: number; imagePath: string }[]; coverPath: string; evidence: Evidence[]; diff --git a/apps/web/src/components/BlueprintEditor.tsx b/apps/web/src/components/BlueprintEditor.tsx new file mode 100644 index 0000000..795bd53 --- /dev/null +++ b/apps/web/src/components/BlueprintEditor.tsx @@ -0,0 +1,316 @@ +import { useState } from 'react'; +import type { Segment, VideoStructureBlueprint } from '../api/types'; +import { CUT_DENSITY_LABELS, GENRE_LABELS, ROLE_LABELS } from '../lib/labels'; +import { EditableField } from './EditableField'; +import { RowControls, moveItem } from './RowControls'; +import { Spinner } from './Spinner'; + +const SEGMENT_ROLES = ['hook', 'setup', 'develop', 'climax', 'closing'] as const; +const CUT_DENSITIES = ['low', 'medium', 'high'] as const; +const GENRES = ['narrative', 'tutorial', 'vlog', 'commentary', 'showcase', 'product', 'other'] as const; +const SUBTITLE_DENSITIES = ['sparse', 'medium', 'dense'] as const; + +/** 把段落 durationRatio 归一化为和约等于 1(满足后端校验)。 */ +function normalizeSegments(segments: Segment[]): Segment[] { + const sum = segments.reduce((acc, s) => acc + (Number(s.durationRatio) || 0), 0); + if (sum <= 0) { + const even = 1 / Math.max(1, segments.length); + return segments.map((s) => ({ ...s, durationRatio: even })); + } + return segments.map((s) => ({ ...s, durationRatio: (Number(s.durationRatio) || 0) / sum })); +} + +interface BlueprintEditorProps { + blueprint: VideoStructureBlueprint; + saving: boolean; + onSave: (next: VideoStructureBlueprint) => void; + onCancel: () => void; +} + +export function BlueprintEditor({ blueprint, saving, onSave, onCancel }: BlueprintEditorProps) { + const [draft, setDraft] = useState(() => + structuredClone ? structuredClone(blueprint) : JSON.parse(JSON.stringify(blueprint)), + ); + + const genreContext = `视频体裁:${draft.videoGenre ?? 'other'}`; + const ratioSum = draft.scriptStructure.segments.reduce((acc, s) => acc + (Number(s.durationRatio) || 0), 0); + + function patch(next: Partial) { + setDraft((d) => ({ ...d, ...next })); + } + + function patchSegment(index: number, next: Partial) { + setDraft((d) => { + const segments = d.scriptStructure.segments.map((s, i) => (i === index ? { ...s, ...next } : s)); + return { ...d, scriptStructure: { ...d.scriptStructure, segments } }; + }); + } + + function setSegments(segments: Segment[]) { + setDraft((d) => ({ ...d, scriptStructure: { ...d.scriptStructure, segments } })); + } + + function addSegment() { + const segments = [ + ...draft.scriptStructure.segments, + { role: 'develop', label: '', durationRatio: 0.15, intent: '', copyPattern: '' } as Segment, + ]; + setSegments(segments); + } + + function handleSave() { + const segments = normalizeSegments(draft.scriptStructure.segments); + onSave({ + ...draft, + videoGenre: draft.videoGenre ?? 'other', + scriptStructure: { ...draft.scriptStructure, segments }, + }); + } + + const inputClass = + 'w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none ring-violet-500 focus:ring-2'; + + return ( +
+
+

编辑模式:抽取结果不准确时可直接修改、增删段落或重排顺序。

+
+ + +
+
+ +
+ 体裁 + +
+ +
+
+

脚本 / 段落结构

+ + 占比合计 {(ratioSum * 100).toFixed(0)}%(保存时自动归一化为 100%) + +
+
    + {draft.scriptStructure.segments.map((seg, index) => ( +
  • +
    + + patchSegment(index, { label: e.target.value })} + placeholder="段落名(可选)" + className="w-36 rounded-lg border border-zinc-700 bg-zinc-950 px-2 py-1 text-sm" + /> + +
    + setSegments(moveItem(draft.scriptStructure.segments, from, to))} + onDelete={(i) => + setSegments(draft.scriptStructure.segments.filter((_, idx) => idx !== i)) + } + /> +
    +
    + patchSegment(index, { intent: v })} + multiline + /> + patchSegment(index, { copyPattern: v })} + multiline + /> +
  • + ))} +
+ +
+ +
+

节奏结构

+
+ + + + +
+
+ + {draft.packagingStructure && ( +
+

包装结构

+
+ + {(['titleBarStyle', 'stickerUsage', 'transitionStyle', 'coverStyle'] as const).map((key) => ( + + ))} +
+
+ )} + +
+ patch({ rationale: v })} + multiline + rows={4} + /> +
+
+ ); +} diff --git a/apps/web/src/components/DirectorArtifactsPanel.tsx b/apps/web/src/components/DirectorArtifactsPanel.tsx new file mode 100644 index 0000000..d0de74a --- /dev/null +++ b/apps/web/src/components/DirectorArtifactsPanel.tsx @@ -0,0 +1,740 @@ +import type { BeatMap, CreativeBrief, DirectorPlan, MigrationPlan, QCReport, ShotList } from '../api/types'; +import { ASSET_TAG_LABELS } from '../lib/labels'; +import { EditableField } from './EditableField'; +import { RowControls, moveItem } from './RowControls'; + +export type DirectorArtifactsViewKey = 'overview' | 'beats' | 'shots' | 'assets' | 'edit' | 'qc' | 'run'; + +const VIEWS: Array<{ key: DirectorArtifactsViewKey; label: string }> = [ + { key: 'overview', label: '总览' }, + { key: 'beats', label: '节奏' }, + { key: 'shots', label: '镜头' }, + { key: 'assets', label: '素材' }, + { key: 'edit', label: '剪辑' }, + { key: 'qc', label: '检查' }, + { key: 'run', label: '生成记录' }, +]; + +const VERDICT_LABELS: Record = { + pass: '通过', + conditional_pass: '有条件通过', + fail: '返工', +}; + +const VERDICT_STYLES: Record = { + pass: 'border-emerald-800 bg-emerald-950/30 text-emerald-200', + conditional_pass: 'border-amber-800 bg-amber-950/30 text-amber-200', + fail: 'border-red-800 bg-red-950/30 text-red-200', +}; + +const SEVERITY_STYLES: Record = { + low: 'bg-zinc-800 text-zinc-300', + medium: 'bg-amber-900/60 text-amber-200', + high: 'bg-red-900/60 text-red-200', +}; + +const STORY_ARC_KEYS = ['opening', 'setup', 'progression', 'turn', 'payoff'] as const; +type StoryArcKey = (typeof STORY_ARC_KEYS)[number]; + +const STORY_ARC_LABELS: Record = { + opening: '开头抓人', + setup: '背景铺垫', + progression: '主体推进', + turn: '重点转折', + payoff: '结尾收束', +}; + +interface DirectorArtifactsPanelProps { + migration: MigrationPlan; + view: DirectorArtifactsViewKey; + onViewChange: (view: DirectorArtifactsViewKey) => void; + showRunView?: boolean; + /** 进入编辑模式时,配合 onChange 把导演层 artifacts 变为可编辑。 */ + editing?: boolean; + onChange?: (next: MigrationPlan) => void; +} + +export function DirectorArtifactsPanel({ + migration, + view, + onViewChange, + showRunView = false, + editing = false, + onChange, +}: DirectorArtifactsPanelProps) { + const hasDirectorArtifacts = Boolean( + migration.creativeBrief && + migration.beatMap && + migration.shotList && + migration.assetPlan && + migration.editDecisionList && + migration.qcReport, + ); + + if (!hasDirectorArtifacts) { + return ( +
+

成片方案说明

+

当前迁移方案暂时还没有可展示的方案说明。

+
+ ); + } + + const brief = migration.creativeBrief!; + const beatMap = migration.beatMap!; + const shotList = migration.shotList!; + const assetPlan = migration.assetPlan!; + const editDecisionList = migration.editDecisionList!; + const qcReport = migration.qcReport!; + const revisionPlan = migration.revisionPlan; + const directorPlan = migration.directorPlan; + const selectedHook = brief.hookCandidates.find((hook) => hook.id === brief.selectedHookId) ?? brief.hookCandidates[0]; + const canEdit = editing && Boolean(onChange); + const visibleViews = showRunView ? VIEWS : VIEWS.filter((item) => item.key !== 'run'); + + const briefContext = `主题:${migration.topic}`; + + function patchBrief(next: Partial) { + onChange?.({ ...migration, creativeBrief: { ...brief, ...next } }); + } + + function patchHookText(hookId: string, text: string) { + onChange?.({ + ...migration, + creativeBrief: { + ...brief, + hookCandidates: brief.hookCandidates.map((h) => (h.id === hookId ? { ...h, text } : h)), + }, + }); + } + + function patchDirectorPlan(next: Partial) { + if (!directorPlan) return; + onChange?.({ ...migration, directorPlan: { ...directorPlan, ...next } }); + } + + function patchBeats(beats: BeatMap['beats']) { + onChange?.({ ...migration, beatMap: { ...beatMap, beats } }); + } + + function patchShots(shots: ShotList['shots']) { + onChange?.({ ...migration, shotList: { ...shotList, shots } }); + } + + return ( +
+
+
+

+ 成片方案说明{canEdit && ' · 编辑中'} +

+

+ {beatMap.beats.length} 个节奏点 · {shotList.shots.length} 个镜头 · 成片检查 {qcReport.totalScore.toFixed(0)} 分 +

+
+ + {VERDICT_LABELS[qcReport.verdict]} + +
+ +
+ {visibleViews.map((item) => ( + + ))} +
+ + {view === 'overview' && ( +
+ {directorPlan && ( +
+

故事怎么推进

+ {canEdit ? ( +
+ {STORY_ARC_KEYS.map((key) => ( + + patchDirectorPlan({ storyArc: { ...directorPlan.storyArc, [key]: v } }) + } + multiline + /> + ))} +
+ ) : ( +
+ {STORY_ARC_KEYS.map((key) => { + const beat = directorPlan.storyArc[key]; + if (!beat) return null; + return ( +
+

{STORY_ARC_LABELS[key]}

+

{beat}

+
+ ); + })} +
+ )} +

+ {directorPlan.shots.length} 个计划镜头 · 重复画面风险 {riskLabel(directorPlan.assetBudget.repeatedVisualRisk)} · 文字包装 {cardPresetLabel(directorPlan.editConstraints.cardStylePreset)} / {cardAnimationLabel(directorPlan.editConstraints.cardAnimationPreset)} +

+
+ )} +
+
+

创作目标

+ {canEdit ? ( +
+ patchBrief({ corePromise: v })} + multiline + /> +
+ patchBrief({ audience: v })} /> + patchBrief({ goal: v })} /> + patchBrief({ platform: v })} assistable={false} /> + patchBrief({ tone: v })} /> +
+
+ ) : ( + <> +

{brief.corePromise}

+
+
+
观众
+
{brief.audience}
+
+
+
目标
+
{brief.goal}
+
+
+
平台
+
{brief.platform}
+
+
+
语气
+
{brief.tone}
+
+
+ + )} +
+ +
+

开场怎么抓人

+ {canEdit ? ( +
+ + patchHookText(selectedHook.id, v)} + multiline + /> + patchBrief({ hookRationale: v })} + multiline + /> +
+ ) : ( + <> +

{selectedHook.text}

+
+ {hookTypeLabel(selectedHook.type)} + + 推荐度 {selectedHook.score.total.toFixed(0)} + +
+

{brief.hookRationale}

+ + )} +
+
+
+ )} + + {view === 'beats' && ( +
+
    + {beatMap.beats.map((beat, index) => ( +
  • +
    + {beat.id} + {canEdit ? ( + patchBeats(moveItem(beatMap.beats, from, to))} + onDelete={(i) => patchBeats(beatMap.beats.filter((_, idx) => idx !== i))} + /> + ) : ( + + {beat.startSec.toFixed(1)}-{beat.endSec.toFixed(1)}s + + )} +
    + {canEdit ? ( +
    + patchBeats(beatMap.beats.map((b, i) => (i === index ? { ...b, purpose: v } : b)))} /> + patchBeats(beatMap.beats.map((b, i) => (i === index ? { ...b, visualChange: v } : b)))} /> + patchBeats(beatMap.beats.map((b, i) => (i === index ? { ...b, screenText: v } : b)))} /> +
    + ) : ( + <> +

    {beat.purpose}

    +

    {beat.visualChange}

    +

    {beat.screenText}

    + + )} +
  • + ))} +
+
+ )} + + {view === 'shots' && ( +
    + {shotList.shots.map((shot, index) => ( +
  • +
    + {shot.shotId} + {canEdit ? ( + patchShots(moveItem(shotList.shots, from, to))} + onDelete={(i) => patchShots(shotList.shots.filter((_, idx) => idx !== i))} + /> + ) : ( + + {sourcePreferenceLabel(shot.sourcePreference)} + + )} +
    + {canEdit ? ( +
    + patchShots(shotList.shots.map((s, i) => (i === index ? { ...s, subject: v } : s)))} /> + patchShots(shotList.shots.map((s, i) => (i === index ? { ...s, action: v } : s)))} /> + patchShots(shotList.shots.map((s, i) => (i === index ? { ...s, motion: v } : s)))} /> +
    + ) : ( + <> +

    {shot.subject}

    +

    {shot.action}

    +

    {shot.motion}

    +
    + {shot.assetNeed.map((tag) => ( + + {assetTagLabel(tag)} + + ))} +
    + + )} +
  • + ))} +
+ )} + + {view === 'assets' && ( +
    + {assetPlan.items.map((item) => ( +
  • +
    + {item.shotId} + + {assetPlanStatusLabel(item)} + +
    +

    {naturalizeInternalText(item.reason)}

    +
    + {item.requiredTags.map((tag) => ( + + {assetTagLabel(tag)} + + ))} +
    +
  • + ))} +
+ )} + + {view === 'edit' && ( +
    + {editDecisionList.decisions.map((decision) => ( +
  • +
    + {decision.id} + + {decision.startSec.toFixed(1)}-{decision.endSec.toFixed(1)}s + +
    +

    {naturalizeInternalText(decision.reason)}

    +

    {decision.subtitle}

    +
    + + {sourceKindLabel(decision.sourceKind)} + + {decision.transition && ( + + {transitionLabel(decision.transition)} + + )} + {decision.overlay && ( + + {overlayLabel(decision.overlay)} + + )} +
    +
  • + ))} +
+ )} + + {view === 'qc' && ( +
+
+

成片检查

+
+ {qcReport.totalScore.toFixed(0)} + / 100 +
+

{naturalizeInternalText(qcReport.rationale)}

+
+ {Object.entries(qcReport.scores).map(([key, value]) => ( +
+
{scoreLabel(key)}
+
{value.toFixed(0)}
+
+ ))} +
+
+ +
+ {qcReport.issues.length === 0 ? ( +
+ 未发现需要返工的问题。 +
+ ) : ( + qcReport.issues.map((issue) => ( +
+
+ + {severityLabel(issue.severity)} + + + {moduleLabel(issue.module)} + + {issue.targetAgent && ( + {agentLabel(issue.targetAgent)} + )} +
+

{naturalizeInternalText(issue.description)}

+

{naturalizeInternalText(issue.suggestion)}

+
+ )) + )} + {revisionPlan && revisionPlan.items.length > 0 && ( +
+

建议怎么改

+
    + {revisionPlan.items.map((item) => ( +
  • + {agentLabel(item.targetAgent)} + · {artifactLabel(item.targetArtifact)} +

    {naturalizeInternalText(item.requiredChange)}

    +
  • + ))} +
+
+ )} +
+
+ )} + + {view === 'run' && ( +
+

生成记录

+

+ 这里集中查看本次生成的步骤 artifacts、模型调用记录和可验证证据,避免它们混在创意、节奏、镜头或素材页签里。 +

+
+ )} +
+ ); +} + +function assetTagLabel(tag: string): string { + return ASSET_TAG_LABELS[tag] ?? naturalizeEnum(tag); +} + +function riskLabel(risk: string): string { + const labels: Record = { + low: '低', + medium: '中', + high: '高', + }; + return labels[risk] ?? naturalizeEnum(risk); +} + +function severityLabel(severity: string): string { + const labels: Record = { + low: '轻微', + medium: '需要注意', + high: '优先处理', + }; + return labels[severity] ?? naturalizeEnum(severity); +} + +function hookTypeLabel(type: string): string { + const labels: Record = { + curiosity: '制造好奇', + contrast: '反差开场', + result_first: '结果前置', + pain_point: '痛点切入', + question: '提问开场', + promise: '承诺收益', + }; + return labels[type] ?? naturalizeEnum(type); +} + +function sourcePreferenceLabel(value: string): string { + const labels: Record = { + user_asset: '优先用你的素材', + existing_asset: '复用已有素材', + real_asset: '优先真实画面', + generated: '可用生成画面补位', + fill: '需要补位', + reference: '可参考样例氛围', + reference_clip: '短参考桥接', + text_card: '可用文字承接', + }; + return labels[value] ?? naturalizeEnum(value); +} + +function sourceKindLabel(value: string): string { + const labels: Record = { + video: '视频素材', + image: '图片素材', + text: '文字素材', + audio: '音频素材', + asset: '项目素材', + user_asset: '你的素材', + reference_clip: '参考氛围片段', + fill_artifact: '补位内容', + text_card: '文字画面', + generated_card: '生成文字画面', + reused_clip: '复用真实画面', + }; + return labels[value] ?? naturalizeEnum(value); +} + +function transitionLabel(value: string): string { + const labels: Record = { + cut: '直接切换', + hard_cut: '直接切换', + fade: '淡入淡出', + crossfade: '淡入淡出', + smoothleft: '横向滑入', + smoothright: '横向滑出', + whip: '快速甩切', + snap: '快速切换', + fadefast: '快速淡入', + }; + return labels[value] ?? naturalizeEnum(value); +} + +function overlayLabel(value: string): string { + const labels: Record = { + lower_third: '底部说明', + title_bar: '标题条', + caption: '上屏字幕', + sticker: '强调贴纸', + none: '无额外包装', + }; + return labels[value] ?? naturalizeEnum(value); +} + +function cardPresetLabel(value: string): string { + const labels: Record = { + minimal_dark: '简洁深色', + social_punch: '社媒强调', + clean_product: '干净产品感', + lifestyle_story: '生活方式', + }; + return labels[value] ?? naturalizeEnum(value); +} + +function cardAnimationLabel(value: string): string { + const labels: Record = { + fade_push: '淡入推近', + pop: '弹出强调', + slide: '滑入', + typewriter: '打字出现', + none: '静态', + }; + return labels[value] ?? naturalizeEnum(value); +} + +function assetPlanStatusLabel(item: NonNullable['items'][number]): string { + if (item.matchedAssetId) return '已找到可用素材'; + if (item.fillArtifactId) return fillStrategyLabel(item.fillStrategy ?? 'fill'); + if (item.gapId) return '需要补素材'; + return '待确认'; +} + +function fillStrategyLabel(value: string): string { + const labels: Record = { + fill: '已做补位', + reused_clip: '复用真实画面', + copy_completion: '用文案补足', + packaging_overlay: '用包装承接', + aigc: '生成补位内容', + reference_clip: '短参考桥接', + stock_clip: '素材库补位', + }; + return labels[value] ?? naturalizeEnum(value); +} + +function agentLabel(value: string): string { + const labels: Record = { + CreativeDirectorAgent: '创作目标', + VideoDirectorAgent: '镜头设计', + AssetProducerAgent: '素材规划', + EditorAgent: '剪辑安排', + RenderAgent: '渲染阶段', + QCReviewerAgent: '成片检查', + RevisionPlannerAgent: '修改建议', + }; + return labels[value] ?? naturalizeEnum(value.replace(/Agent$/, '')); +} + +function artifactLabel(value: string): string { + const labels: Record = { + DirectorPlan: '故事和镜头方案', + CreativeBrief: '创作目标', + BeatMap: '节奏安排', + ShotList: '镜头列表', + AssetPlan: '素材安排', + FillPlan: '补位方案', + EditDecisionList: '剪辑时间线', + Timeline: '时间线', + QCReport: '成片检查', + RevisionPlan: '修改建议', + script: '脚本', + storyboard: '分镜', + assetPlan: '素材安排', + timeline: '时间线', + }; + return labels[value] ?? naturalizeEnum(value); +} + +function moduleLabel(value: string): string { + const labels: Record = { + hook: '开场', + structure: '结构', + pacing: '节奏', + shot: '镜头', + asset: '素材', + caption: '字幕包装', + captions: '字幕包装', + render: '渲染', + explainability: '解释性', + risk: '风险', + story: '故事收口', + timeline: '时间线', + }; + return labels[value] ?? artifactLabel(value); +} + +function naturalizeInternalText(text: string | undefined): string { + return (text ?? '') + .replace(/\bDirectorPlan\b/g, '故事和镜头方案') + .replace(/\bCreativeBrief\b/g, '创作目标') + .replace(/\bBeatMap\b/g, '节奏安排') + .replace(/\bShotList\b/g, '镜头列表') + .replace(/\bAssetPlan\b/g, '素材安排') + .replace(/\bEditDecisionList\b/g, '剪辑时间线') + .replace(/\bQCReport\b/g, '成片检查') + .replace(/\bRevisionPlan\b/g, '修改建议') + .replace(/\bCreativeDirectorAgent\b/g, '创作目标') + .replace(/\bVideoDirectorAgent\b/g, '镜头设计') + .replace(/\bAssetProducerAgent\b/g, '素材规划') + .replace(/\bEditorAgent\b/g, '剪辑安排') + .replace(/\bRenderAgent\b/g, '渲染阶段') + .replace(/\bQCReviewerAgent\b/g, '成片检查') + .replace(/\bRevisionPlannerAgent\b/g, '修改建议') + .replace(/\bproof\b/gi, '证明效果') + .replace(/\bpayoff\b/gi, '结尾结果') + .replace(/\bb[-_ ]?roll\b/gi, '氛围画面') + .replace(/\btext[_ -]?card\b/gi, '文字画面') + .replace(/\bmotionPreset\b/g, '画面运动方式') + .replace(/\btransitionPreset\b/g, '转场方式') + .replace(/\bcardStylePreset\b/g, '文字包装样式') + .replace(/\bcardAnimationPreset\b/g, '文字出现方式') + .replace(/\bassetPlan\b/g, '素材安排') + .replace(/\btargetArtifact\b/g, '要调整的内容') + .replace(/\btargetAgent\b/g, '处理方向') + .replace(/Ken Burns/gi, '轻微推近') + .replace(/push[-_ ]?in/gi, '推近') + .replace(/\bpan\b/gi, '平移'); +} + +function naturalizeEnum(value: string): string { + return value + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function scoreLabel(key: string): string { + const labels: Record = { + hookStrength: '开场吸引力', + structureClarity: '结构清晰度', + pacingChange: '节奏变化', + shotExecutability: '镜头可执行性', + assetCoverage: '素材覆盖', + captionsPackaging: '字幕包装', + renderReadiness: '渲染准备度', + explainability: '解释清晰度', + riskControl: '风险控制', + }; + return labels[key] ?? key; +} diff --git a/apps/web/src/components/EditableField.tsx b/apps/web/src/components/EditableField.tsx new file mode 100644 index 0000000..1956447 --- /dev/null +++ b/apps/web/src/components/EditableField.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { assist } from '../api/client'; +import { Spinner } from './Spinner'; + +export interface EditableFieldProps { + value: string; + onChange: (next: string) => void; + /** 该字段的语义名,喂给 AI 协助(如「段落意图」「Hook 文案」)。 */ + field: string; + /** 额外上下文(主题、段落角色等),帮助 AI 给出更贴合的候选。 */ + context?: string; + label?: string; + placeholder?: string; + multiline?: boolean; + rows?: number; + /** 是否提供 AI 协助按钮,默认 true。 */ + assistable?: boolean; + className?: string; +} + +/** 可编辑文本字段 + 「AI 协助」(生成候选 → 一键采用)。 */ +export function EditableField({ + value, + onChange, + field, + context, + label, + placeholder, + multiline = false, + rows = 2, + assistable = true, + className = '', +}: EditableFieldProps) { + const [open, setOpen] = useState(false); + const [instruction, setInstruction] = useState(''); + const [loading, setLoading] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [error, setError] = useState(null); + + const inputClass = + 'w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none ring-violet-500 focus:ring-2'; + + async function runAssist() { + setLoading(true); + setError(null); + try { + const res = await assist({ field, instruction: instruction.trim(), current: value, context, count: 3 }); + setSuggestions(res.suggestions); + if (res.suggestions.length === 0) setError('未生成候选,换个描述试试'); + } catch (e) { + setError(e instanceof Error ? e.message : 'AI 协助失败'); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {label && } + {assistable && ( + + )} +
+ + {multiline ? ( +