hyper-animator-codex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # hyper-animator-codex
2
+
3
+ Install the `hyper-animator-codex` Codex skill from npm.
4
+
5
+ The skill guides Codex through a HyperFrames animation workflow: natural-language brief, clarification questions, catalog-map intent matching, style and motion selection, HTML animation authoring, validation, user approval, and render handoff.
6
+
7
+ ## Install
8
+
9
+ Use it directly with `npx`:
10
+
11
+ ```bash
12
+ npx hyper-animator-codex install
13
+ ```
14
+
15
+ To replace an existing installed copy:
16
+
17
+ ```bash
18
+ npx hyper-animator-codex install --force
19
+ ```
20
+
21
+ Or install the CLI globally:
22
+
23
+ ```bash
24
+ npm install -g hyper-animator-codex
25
+ hyper-animator-codex install --force
26
+ ```
27
+
28
+ By default the installer copies the skill to:
29
+
30
+ ```text
31
+ ${CODEX_HOME:-$HOME/.codex}/skills/hyper-animator-codex
32
+ ```
33
+
34
+ Use a custom Codex skills directory:
35
+
36
+ ```bash
37
+ npx hyper-animator-codex install --target /path/to/codex/skills
38
+ ```
39
+
40
+ ## Contents
41
+
42
+ - `skills/hyper-animator-codex/SKILL.md`: Codex skill instructions.
43
+ - `skills/hyper-animator-codex/references/`: HyperFrames catalog map, workflow guide, pseudocode, and request examples.
44
+ - `skills/hyper-animator-codex/scripts/validate_hyperframes_html.py`: static pre-render HTML quality gate.
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ npm test
50
+ npm run pack:check
51
+ ```
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { installSkill, resolveCodexSkillsRoot, SKILL_NAME } from "../lib/install-skill.mjs";
3
+
4
+ function printHelp() {
5
+ console.log(`Usage:
6
+ hyper-animator-codex install [--force] [--target <skills-dir>]
7
+ hyper-animator-codex path
8
+ hyper-animator-codex help
9
+
10
+ Commands:
11
+ install Install the packaged ${SKILL_NAME} skill into Codex
12
+ path Print the default Codex skills directory
13
+ help Show this help
14
+
15
+ Options:
16
+ --force Replace an existing installed skill
17
+ --target <dir> Install into a specific Codex skills directory
18
+ `);
19
+ }
20
+
21
+ function parseInstallArgs(args) {
22
+ const parsed = {
23
+ force: false,
24
+ targetRoot: undefined,
25
+ };
26
+
27
+ for (let index = 0; index < args.length; index += 1) {
28
+ const arg = args[index];
29
+ if (arg === "--force") {
30
+ parsed.force = true;
31
+ } else if (arg === "--target") {
32
+ const value = args[index + 1];
33
+ if (!value) {
34
+ throw new Error("--target requires a directory");
35
+ }
36
+ parsed.targetRoot = value;
37
+ index += 1;
38
+ } else {
39
+ throw new Error(`Unknown option: ${arg}`);
40
+ }
41
+ }
42
+
43
+ return parsed;
44
+ }
45
+
46
+ async function main() {
47
+ const [command = "install", ...args] = process.argv.slice(2);
48
+
49
+ if (command === "help" || command === "--help" || command === "-h") {
50
+ printHelp();
51
+ return;
52
+ }
53
+
54
+ if (command === "path") {
55
+ console.log(resolveCodexSkillsRoot());
56
+ return;
57
+ }
58
+
59
+ if (command !== "install") {
60
+ throw new Error(`Unknown command: ${command}`);
61
+ }
62
+
63
+ const options = parseInstallArgs(args);
64
+ const result = await installSkill(options);
65
+
66
+ console.log(`Installed ${result.skillName}`);
67
+ console.log(`Source: ${result.sourcePath}`);
68
+ console.log(`Target: ${result.installedPath}`);
69
+ }
70
+
71
+ main().catch((error) => {
72
+ console.error(`Error: ${error.message}`);
73
+ process.exitCode = 1;
74
+ });
@@ -0,0 +1,69 @@
1
+ import { cp, mkdir, rm, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const SKILL_NAME = "hyper-animator-codex";
7
+
8
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
9
+
10
+ async function pathExists(path) {
11
+ try {
12
+ await stat(path);
13
+ return true;
14
+ } catch (error) {
15
+ if (error && error.code === "ENOENT") {
16
+ return false;
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ function shouldCopy(src) {
23
+ const name = basename(src);
24
+ return name !== ".DS_Store" && name !== "__pycache__" && !name.endsWith(".pyc");
25
+ }
26
+
27
+ export function resolveCodexSkillsRoot(env = process.env) {
28
+ if (env.CODEX_HOME) {
29
+ return join(env.CODEX_HOME, "skills");
30
+ }
31
+
32
+ const home = env.HOME || homedir();
33
+ if (!home) {
34
+ throw new Error("Cannot resolve Codex skills directory: HOME is not set");
35
+ }
36
+
37
+ return join(home, ".codex", "skills");
38
+ }
39
+
40
+ export async function installSkill(options = {}) {
41
+ const targetRoot = options.targetRoot || resolveCodexSkillsRoot(options.env);
42
+ const sourcePath = options.sourcePath || join(packageRoot, "skills", SKILL_NAME);
43
+ const installedPath = join(targetRoot, SKILL_NAME);
44
+ const force = Boolean(options.force);
45
+
46
+ if (!(await pathExists(sourcePath))) {
47
+ throw new Error(`Packaged skill not found at ${sourcePath}`);
48
+ }
49
+
50
+ await mkdir(targetRoot, { recursive: true });
51
+
52
+ if (await pathExists(installedPath)) {
53
+ if (!force) {
54
+ throw new Error(`${SKILL_NAME} already exists at ${installedPath}; rerun with --force to replace it`);
55
+ }
56
+ await rm(installedPath, { recursive: true, force: true });
57
+ }
58
+
59
+ await cp(sourcePath, installedPath, {
60
+ recursive: true,
61
+ filter: shouldCopy,
62
+ });
63
+
64
+ return {
65
+ skillName: SKILL_NAME,
66
+ sourcePath,
67
+ installedPath,
68
+ };
69
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "hyper-animator-codex",
3
+ "version": "0.1.0",
4
+ "description": "Install the Hyper Animator Codex skill for Codex.",
5
+ "type": "module",
6
+ "bin": {
7
+ "hyper-animator-codex": "bin/hyper-animator-codex.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "pack:check": "npm pack --dry-run"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "lib/",
16
+ "skills/hyper-animator-codex/",
17
+ "README.md"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+ssh://git@github.com/realpkuasule/hyper-animator-codex.git"
22
+ },
23
+ "keywords": [
24
+ "codex",
25
+ "codex-skill",
26
+ "hyperframes",
27
+ "animation",
28
+ "video",
29
+ "gsap"
30
+ ],
31
+ "author": "",
32
+ "license": "UNLICENSED",
33
+ "engines": {
34
+ "node": ">=18.17"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: hyper-animator-codex
3
+ description: Use when a user asks Codex to create, plan, author, customize, validate, preview, or render a HyperFrames, HTML, or GSAP animation/video from natural-language requirements, including product demos, code demos, data videos, podcast captions, social shorts, catalog block/component assembly, or new HyperFrames HTML.
4
+ ---
5
+
6
+ # Hyper Animator Codex
7
+
8
+ ## Overview
9
+
10
+ Turn a natural-language animation or video brief into a validated HyperFrames HTML composition and render handoff. Use catalog items for intent matching and visual references, but keep user confirmation before render.
11
+
12
+ ## Required Flow
13
+
14
+ 1. Capture the raw request and extract an initial intent profile: purpose, format, duration, content inputs, style tags, motion tags, and needed roles.
15
+ 2. If purpose, format, or core content is unclear, ask the first clarification round before choosing catalog items.
16
+ 3. Read `references/hyperframes-catalog-map.json` and score candidates by keyword, intent domain, format, role, style, motion, and constraints.
17
+ 4. Pick candidate blocks/components for main scene, caption, effects, transitions, and outro.
18
+ 5. Decide generation mode:
19
+ - `generate_new_hyperframes_html` when the user asks to write HTML, create a new effect, customize style, match a brand, use complex animation, or when component snippets are only paste placeholders.
20
+ - `assemble_existing_catalog_items` only when the user asks to use existing catalog items or quickly compose installed blocks/components.
21
+ 6. Ask the second clarification round with candidate context: visual direction, motion rhythm, generation mode, and any candidate tradeoffs.
22
+ 7. Write or assemble HTML.
23
+ 8. Run pre-render quality gates.
24
+ 9. Show a concise plan summary and preview path or HTML file to the user. Ask for confirmation before video render.
25
+ 10. Render only after user confirmation, then report output path and any caveats.
26
+
27
+ ## Interactive Questions
28
+
29
+ Prefer Codex interactive question tools such as `AskUserQuestion` or `request_user_input` when available. If they are unavailable, ask the same questions as ordinary concise text and continue after the user answers.
30
+
31
+ Use two rounds:
32
+
33
+ - Round 1: purpose, format, duration, platform, required content, brand assets, and whether video render is expected in this turn.
34
+ - Round 2: after catalog scoring, ask about style, motion, candidate selection, and generation mode.
35
+
36
+ Do not ask everything upfront when the brief is already specific. Ask only for missing or decision-changing information.
37
+
38
+ ## References
39
+
40
+ - Read `references/hyperframes-intent-workflow.md` for the full AskUserQuestion workflow, generation-mode rules, scoring model, and render confirmation requirements.
41
+ - Read `references/hyperframes-catalog-map.json` whenever selecting catalog candidates or determining visual references.
42
+ - Read `references/hyperframes-agent-pseudocode.ts` when implementing the end-to-end loop or when the correct sequence is ambiguous.
43
+ - Use `references/examples/*.json` for sanity checks against common request shapes.
44
+
45
+ ## Catalog Rules
46
+
47
+ Hard constraints beat score:
48
+
49
+ - Portrait requests prioritize portrait items such as `instagram-follow`, `tiktok-follow`, `spotify-card`, and `flowchart-vertical`.
50
+ - Caption requests prioritize `caption-*` components.
51
+ - Complete scene requests prioritize block items with `main_scene`.
52
+ - Exclude `code-morph` by default because it appears in failed items.
53
+ - Component `install.includeSnippet` values that only say `<!-- paste from ... -->` are not renderable HTML. Resolve the real snippet or switch to `generate_new_hyperframes_html`.
54
+
55
+ ## HTML Quality Gates
56
+
57
+ Before render, run:
58
+
59
+ ```bash
60
+ python3 scripts/validate_hyperframes_html.py path/to/composition.html
61
+ ```
62
+
63
+ Generated block HTML must include:
64
+
65
+ - fixed `data-width`, `data-height`, and `data-duration`;
66
+ - `data-composition-id`;
67
+ - scoped CSS under `[data-composition-id="..."]` or another unique composition selector;
68
+ - `gsap.timeline({ paused: true })`;
69
+ - `window.__timelines[id]` registration;
70
+ - no unresolved `<!-- paste from ... -->` placeholders;
71
+ - no wall-clock `Date.now()` or `setInterval()` driving primary timeline progress;
72
+ - readable text at the target video dimensions.
73
+
74
+ If the validator fails, fix the HTML before asking the user to approve render.
75
+
76
+ ## Render Handoff
77
+
78
+ Before rendering, summarize:
79
+
80
+ - generation mode;
81
+ - selected or referenced catalog items;
82
+ - dimensions and duration;
83
+ - content assumptions;
84
+ - preview location;
85
+ - validator result.
86
+
87
+ Ask the user whether to render or revise. If rendering tools or project-specific commands are unavailable, stop at validated HTML and report the exact missing render command or dependency.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Hyper Animator Codex"
3
+ short_description: "Turn animation briefs into HyperFrames videos"
4
+ default_prompt: "Use $hyper-animator-codex to turn my animation brief into HyperFrames HTML and a rendered video."
@@ -0,0 +1,18 @@
1
+ {
2
+ "rawRequest": "做一个开发者工具 CLI 安装演示,黑色终端风,展示命令输入和安装成功。",
3
+ "expectedIntent": {
4
+ "purpose": "developer_demo",
5
+ "format": "landscape_16_9",
6
+ "styleTags": [
7
+ "dark"
8
+ ],
9
+ "neededRoles": [
10
+ "main_scene"
11
+ ]
12
+ },
13
+ "likelyItems": [
14
+ "code-snippet-apple-terminal-pro",
15
+ "code-snippet-apple-terminal-homebrew",
16
+ "grain-overlay"
17
+ ]
18
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "rawRequest": "给播客采访加一个嘉宾名牌,再加口播字幕,风格简洁高级。",
3
+ "expectedIntent": {
4
+ "purpose": "podcast_interview",
5
+ "format": "landscape_16_9",
6
+ "styleTags": [
7
+ "minimal",
8
+ "premium"
9
+ ],
10
+ "neededRoles": [
11
+ "main_scene",
12
+ "caption",
13
+ "effect"
14
+ ]
15
+ },
16
+ "likelyItems": [
17
+ "lt-clean-bar",
18
+ "lt-soft-pill",
19
+ "caption-editorial-emphasis",
20
+ "grain-overlay"
21
+ ]
22
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "rawRequest": "做一个 AI 产品发布视频,展示 App 界面,Apple 风,横屏,最后有 logo 收尾。",
3
+ "expectedIntent": {
4
+ "purpose": "product_launch",
5
+ "format": "landscape_16_9",
6
+ "styleTags": [
7
+ "apple_like",
8
+ "premium"
9
+ ],
10
+ "neededRoles": [
11
+ "main_scene",
12
+ "device_showcase",
13
+ "outro"
14
+ ]
15
+ },
16
+ "likelyItems": [
17
+ "app-showcase",
18
+ "vfx-iphone-device",
19
+ "logo-outro",
20
+ "vignette",
21
+ "shimmer-sweep"
22
+ ]
23
+ }
@@ -0,0 +1,266 @@
1
+ type Format = "landscape_16_9" | "portrait_9_16" | "square" | "custom" | "flexible" | "unknown";
2
+ type GenerationMode = "assemble_existing_catalog_items" | "generate_new_hyperframes_html";
3
+
4
+ type IntentProfile = {
5
+ rawRequest: string;
6
+ purpose?: string;
7
+ format?: Format;
8
+ generationMode?: GenerationMode;
9
+ styleTags: string[];
10
+ motionTags: string[];
11
+ neededRoles: string[];
12
+ durationTarget?: number;
13
+ contentInputs: Record<string, unknown>;
14
+ };
15
+
16
+ type CatalogItem = {
17
+ id: string;
18
+ title: string;
19
+ type: "block" | "component";
20
+ intentDomains: string[];
21
+ naturalLanguageTriggers: string[];
22
+ styleTags: string[];
23
+ motionTags: string[];
24
+ assetRoles: string[];
25
+ format: { width?: number; height?: number; aspect: Format; durationSeconds?: number };
26
+ install: { command?: string; path?: string; includeSnippet?: string };
27
+ generationHints?: {
28
+ htmlStrategy?: {
29
+ assembleExisting?: string;
30
+ generateNew?: string;
31
+ };
32
+ patternsRequiredForGenerateNew?: boolean;
33
+ componentSnippetWarning?: string;
34
+ };
35
+ };
36
+
37
+ type ScoredItem = CatalogItem & { score: number; reasons: string[] };
38
+ type FinalPlan = {
39
+ generationMode: GenerationMode;
40
+ selectedItems: ScoredItem[];
41
+ width: number;
42
+ height: number;
43
+ duration: number;
44
+ assumptions: string[];
45
+ };
46
+
47
+ declare function AskUserQuestion(input: unknown): Promise<Record<string, unknown>>;
48
+ declare function renderVideo(input: { html: string; width: number; height: number; duration: number }): Promise<string>;
49
+ declare function readInstalledSnippet(path: string): Promise<string>;
50
+ declare function generateWithLLM(input: unknown): Promise<string>;
51
+
52
+ export async function runHyperFramesAgent(userRequest: string, catalogMap: { items: CatalogItem[] }) {
53
+ let profile = extractInitialIntent(userRequest);
54
+
55
+ if (!profile.purpose || !profile.format || profile.format === "unknown") {
56
+ const answers = await AskUserQuestion({
57
+ purpose: "clarify_intent_and_format",
58
+ questions: buildFirstRoundQuestions(),
59
+ });
60
+ profile = mergeAnswers(profile, answers);
61
+ }
62
+
63
+ let candidates = scoreCatalogItems(profile, catalogMap.items).filter((item) => item.id !== "code-morph");
64
+ const draftPlan = selectBlocksAndComponents(profile, candidates);
65
+
66
+ if (!profile.generationMode || generationModeNeedsConfirmation(profile, draftPlan)) {
67
+ const answer = await AskUserQuestion({
68
+ purpose: "clarify_generation_mode",
69
+ question: "你希望这次是快速组合现有 HyperFrames catalog 条目,还是生成一个新的完整 HTML 动画?",
70
+ choices: ["assemble_existing_catalog_items", "generate_new_hyperframes_html"],
71
+ recommendation: recommendGenerationMode(profile, draftPlan),
72
+ });
73
+ profile = mergeAnswers(profile, answer);
74
+ }
75
+
76
+ const styleAnswers = await AskUserQuestion({
77
+ purpose: "clarify_style_motion",
78
+ candidateContext: draftPlan.selectedItems.map((item) => item.id),
79
+ questions: buildStyleQuestions(),
80
+ });
81
+ profile = mergeAnswers(profile, styleAnswers);
82
+
83
+ candidates = scoreCatalogItems(profile, catalogMap.items).filter((item) => item.id !== "code-morph");
84
+ const finalPlan = selectBlocksAndComponents(profile, candidates);
85
+ finalPlan.generationMode = profile.generationMode ?? recommendGenerationMode(profile, finalPlan);
86
+
87
+ const html = finalPlan.generationMode === "assemble_existing_catalog_items"
88
+ ? await assembleExistingCatalogHtml(finalPlan, profile)
89
+ : await generateNewHyperFramesHtml(finalPlan, profile);
90
+
91
+ const quality = runPreRenderQualityGates(html, finalPlan);
92
+ if (!quality.ok) {
93
+ return { status: "quality_gate_failed", finalPlan, html, failures: quality.failures };
94
+ }
95
+
96
+ const approval = await AskUserQuestion({
97
+ purpose: "user_validation_before_render",
98
+ summary: summarizePlan(finalPlan),
99
+ previewHtml: html,
100
+ questions: [
101
+ { id: "approved", question: "这个方案可以进入渲染吗?", choices: ["可以", "需要修改"] },
102
+ { id: "feedback", question: "如果需要修改,请说明要改的地方。", optional: true },
103
+ ],
104
+ });
105
+
106
+ if (approval.approved !== "可以") {
107
+ return { status: "needs_revision", html, finalPlan, feedback: approval.feedback };
108
+ }
109
+
110
+ return renderVideo({ html, width: finalPlan.width, height: finalPlan.height, duration: finalPlan.duration });
111
+ }
112
+
113
+ function recommendGenerationMode(profile: IntentProfile, plan: FinalPlan): GenerationMode {
114
+ if (/写|生成|自定义|新的|html|动画/i.test(profile.rawRequest)) return "generate_new_hyperframes_html";
115
+ const hasComponentPasteOnly = plan.selectedItems.some((item) => item.type === "component" && item.install.includeSnippet?.startsWith("<!-- paste from"));
116
+ if (hasComponentPasteOnly) return "generate_new_hyperframes_html";
117
+ return "assemble_existing_catalog_items";
118
+ }
119
+
120
+ function generationModeNeedsConfirmation(profile: IntentProfile, plan: FinalPlan): boolean {
121
+ if (/用现有|catalog|调用|组合/i.test(profile.rawRequest)) return false;
122
+ if (/写|生成|自定义|新的|html|动画/i.test(profile.rawRequest)) return false;
123
+ return plan.selectedItems.some((item) => item.type === "component" && item.install.includeSnippet?.startsWith("<!-- paste from"));
124
+ }
125
+
126
+ async function assembleExistingCatalogHtml(plan: FinalPlan, profile: IntentProfile): Promise<string> {
127
+ const bodyParts: string[] = [];
128
+ for (const item of plan.selectedItems) {
129
+ if (item.type === "block" && item.install.includeSnippet?.startsWith("<div")) {
130
+ bodyParts.push(item.install.includeSnippet);
131
+ continue;
132
+ }
133
+ if (item.type === "component" && item.install.path) {
134
+ bodyParts.push(await readInstalledSnippet(item.install.path));
135
+ continue;
136
+ }
137
+ bodyParts.push(`<!-- Missing snippet for ${item.id}; resolve before render. -->`);
138
+ }
139
+
140
+ return `<!doctype html>
141
+ <html>
142
+ <head>
143
+ <meta charset="utf-8" />
144
+ <meta name="viewport" content="width=${plan.width}, height=${plan.height}" />
145
+ <style>html,body{margin:0;width:${plan.width}px;height:${plan.height}px;overflow:hidden;}</style>
146
+ </head>
147
+ <body>
148
+ ${bodyParts.join("\n")}
149
+ </body>
150
+ </html>`;
151
+ }
152
+
153
+ async function generateNewHyperFramesHtml(plan: FinalPlan, profile: IntentProfile): Promise<string> {
154
+ return generateWithLLM({
155
+ mode: "generate_new_hyperframes_html",
156
+ patternsReference: "/Users/zhichao/claude/hf-projects/HyperFrames-AI-Generation-Patterns-codex.md",
157
+ requirements: [
158
+ "Generate complete HyperFrames HTML, not just data-composition-src wrapper.",
159
+ "Use fixed data-composition-id, data-width, data-height, data-duration.",
160
+ "Use gsap.timeline({ paused: true }) and register window.__timelines[id].",
161
+ "Scope CSS under [data-composition-id] or unique component id.",
162
+ "Pad timeline to declared duration.",
163
+ "Avoid Date.now(), hover-only animation, and unbounded RAF for primary progress.",
164
+ "Handle fonts/assets/WebGL readiness before timeline registration; provide fallback for WebGL.",
165
+ ],
166
+ selectedReferences: plan.selectedItems.map((item) => ({ id: item.id, title: item.title, reasons: item.reasons })),
167
+ profile,
168
+ dimensions: { width: plan.width, height: plan.height, duration: plan.duration },
169
+ });
170
+ }
171
+
172
+ function runPreRenderQualityGates(html: string, plan: FinalPlan): { ok: boolean; failures: string[] } {
173
+ const failures: string[] = [];
174
+ if (plan.generationMode === "generate_new_hyperframes_html") {
175
+ if (!/data-composition-id=/.test(html)) failures.push("missing data-composition-id");
176
+ if (!/data-duration=/.test(html)) failures.push("missing data-duration");
177
+ if (!/data-width=/.test(html) || !/data-height=/.test(html)) failures.push("missing data-width/data-height");
178
+ if (!/gsap\.timeline\(\{\s*paused:\s*true/.test(html)) failures.push("missing paused GSAP timeline");
179
+ if (!/window\.__timelines/.test(html)) failures.push("missing window.__timelines registration");
180
+ }
181
+ if (/<!-- paste from /.test(html)) failures.push("component paste instruction was not resolved to real snippet");
182
+ if (/Date\.now\(/.test(html)) failures.push("uses Date.now for timing");
183
+ if (/setInterval\(/.test(html)) failures.push("uses setInterval for primary timing");
184
+ return { ok: failures.length === 0, failures };
185
+ }
186
+
187
+ function extractInitialIntent(rawRequest: string): IntentProfile {
188
+ const profile: IntentProfile = { rawRequest, styleTags: [], motionTags: [], neededRoles: [], contentInputs: {} };
189
+ if (/产品|app|发布|功能/.test(rawRequest)) profile.purpose = "product_launch";
190
+ if (/代码|编程|cli|终端|vscode/i.test(rawRequest)) profile.purpose = "developer_demo";
191
+ if (/数据|图表|地图|增长|复盘/.test(rawRequest)) profile.purpose = "data_visualization";
192
+ if (/播客|采访|嘉宾|lower third/i.test(rawRequest)) profile.purpose = "podcast_interview";
193
+ if (/字幕|口播|caption|shorts|tiktok|reels/i.test(rawRequest)) profile.neededRoles.push("caption");
194
+ if (/转场|切换|transition/i.test(rawRequest)) profile.neededRoles.push("transition");
195
+ if (/logo|片尾|outro/i.test(rawRequest)) profile.neededRoles.push("outro");
196
+ if (/竖屏|9:16|tiktok|shorts|reels/i.test(rawRequest)) profile.format = "portrait_9_16";
197
+ else if (/横屏|16:9|youtube|b站|bilibili/i.test(rawRequest)) profile.format = "landscape_16_9";
198
+ else profile.format = "unknown";
199
+ if (/用现有|catalog|调用|组合/i.test(rawRequest)) profile.generationMode = "assemble_existing_catalog_items";
200
+ if (/写|生成|自定义|新的|html|动画/i.test(rawRequest)) profile.generationMode = "generate_new_hyperframes_html";
201
+ if (/apple|苹果|高级|premium/i.test(rawRequest)) profile.styleTags.push("apple_like", "premium");
202
+ if (/电影|cinematic|暗调/i.test(rawRequest)) profile.styleTags.push("cinematic", "dark");
203
+ if (/极简|clean|minimal/i.test(rawRequest)) profile.styleTags.push("minimal");
204
+ if (/故障|赛博|glitch|cyber/i.test(rawRequest)) profile.styleTags.push("cyberpunk");
205
+ return profile;
206
+ }
207
+
208
+ function scoreCatalogItems(profile: IntentProfile, items: CatalogItem[]): ScoredItem[] {
209
+ return items.map((item) => {
210
+ let score = 0;
211
+ const reasons: string[] = [];
212
+ const request = profile.rawRequest.toLowerCase();
213
+ const keywordHits = item.naturalLanguageTriggers.filter((k) => request.includes(k.toLowerCase()));
214
+ if (keywordHits.length) { score += Math.min(0.30, keywordHits.length * 0.05); reasons.push(`keyword: ${keywordHits.slice(0, 4).join(", ")}`); }
215
+ if (profile.purpose && item.intentDomains.includes(profile.purpose)) { score += 0.25; reasons.push(`intent: ${profile.purpose}`); }
216
+ if (profile.format && item.format.aspect === profile.format) { score += 0.15; reasons.push(`format: ${profile.format}`); }
217
+ const roleHits = profile.neededRoles.filter((role) => item.assetRoles.includes(role));
218
+ if (roleHits.length) { score += Math.min(0.10, roleHits.length * 0.05); reasons.push(`role: ${roleHits.join(", ")}`); }
219
+ const styleHits = profile.styleTags.filter((tag) => item.styleTags.includes(tag));
220
+ if (styleHits.length) { score += Math.min(0.10, styleHits.length * 0.04); reasons.push(`style: ${styleHits.join(", ")}`); }
221
+ const motionHits = profile.motionTags.filter((tag) => item.motionTags.includes(tag));
222
+ if (motionHits.length) { score += Math.min(0.05, motionHits.length * 0.025); reasons.push(`motion: ${motionHits.join(", ")}`); }
223
+ return { ...item, score: Number(score.toFixed(3)), reasons };
224
+ }).sort((a, b) => b.score - a.score);
225
+ }
226
+
227
+ function buildFirstRoundQuestions() {
228
+ return [
229
+ { id: "purpose", question: "你这次视频/动画主要用于什么场景?", choices: ["product_launch", "developer_demo", "data_visualization", "podcast_interview", "social_media"] },
230
+ { id: "format", question: "目标画幅是什么?", choices: ["landscape_16_9", "portrait_9_16", "square", "unknown"] },
231
+ ];
232
+ }
233
+
234
+ function buildStyleQuestions() {
235
+ return [
236
+ { id: "style", question: "这次更想要哪种视觉方向?", choices: ["apple_like", "cinematic", "minimal", "social_dynamic", "cyberpunk", "editorial"] },
237
+ { id: "motion", question: "动效更偏哪种节奏?", choices: ["steady_premium", "fast_impact", "soft_fluid", "glitch_cyber"] },
238
+ ];
239
+ }
240
+
241
+ function selectBlocksAndComponents(profile: IntentProfile, candidates: ScoredItem[]): FinalPlan {
242
+ const main = candidates.find((item) => item.type === "block" && item.assetRoles.includes("main_scene"));
243
+ const captions = candidates.filter((item) => item.assetRoles.includes("caption") && item.type === "component").slice(0, 1);
244
+ const effects = candidates.filter((item) => item.assetRoles.includes("effect") && item.type === "component").slice(0, 2);
245
+ const outro = profile.neededRoles.includes("outro") ? candidates.find((item) => item.assetRoles.includes("outro")) : undefined;
246
+ const selectedItems = [main, ...captions, ...effects, outro].filter(Boolean) as ScoredItem[];
247
+ const width = main?.format.width ?? (profile.format === "portrait_9_16" ? 1080 : 1920);
248
+ const height = main?.format.height ?? (profile.format === "portrait_9_16" ? 1920 : 1080);
249
+ const duration = selectedItems.reduce((sum, item) => sum + (item.format.durationSeconds ?? 0), 0) || profile.durationTarget || 10;
250
+ const generationMode = profile.generationMode ?? "generate_new_hyperframes_html";
251
+ return { generationMode, selectedItems, width, height, duration, assumptions: [] };
252
+ }
253
+
254
+ function mergeAnswers(profile: IntentProfile, answers: Record<string, unknown>): IntentProfile {
255
+ return { ...profile, ...answers } as IntentProfile;
256
+ }
257
+
258
+ function summarizePlan(plan: FinalPlan) {
259
+ return {
260
+ generationMode: plan.generationMode,
261
+ selectedItems: plan.selectedItems.map((item) => ({ id: item.id, title: item.title, score: item.score, reasons: item.reasons })),
262
+ width: plan.width,
263
+ height: plan.height,
264
+ duration: plan.duration,
265
+ };
266
+ }