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 +51 -0
- package/bin/hyper-animator-codex.mjs +74 -0
- package/lib/install-skill.mjs +69 -0
- package/package.json +39 -0
- package/skills/hyper-animator-codex/SKILL.md +87 -0
- package/skills/hyper-animator-codex/agents/openai.yaml +4 -0
- package/skills/hyper-animator-codex/references/examples/request-code_demo.json +18 -0
- package/skills/hyper-animator-codex/references/examples/request-podcast_caption.json +22 -0
- package/skills/hyper-animator-codex/references/examples/request-product_launch.json +23 -0
- package/skills/hyper-animator-codex/references/hyperframes-agent-pseudocode.ts +266 -0
- package/skills/hyper-animator-codex/references/hyperframes-catalog-map.json +13402 -0
- package/skills/hyper-animator-codex/references/hyperframes-intent-workflow.md +191 -0
- package/skills/hyper-animator-codex/scripts/validate_hyperframes_html.py +88 -0
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,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
|
+
}
|