@amg555/frameforge-mcp 0.0.0-stage → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
  2. package/.claude-plugin/marketplace.json +45 -0
  3. package/.gitattributes +19 -0
  4. package/.mcp.json +45 -0
  5. package/CHANGELOG.md +447 -0
  6. package/CLAUDE.md +320 -0
  7. package/CONTRIBUTING.md +112 -0
  8. package/LICENSE +21 -0
  9. package/Procfile +1 -0
  10. package/README.md +377 -2
  11. package/SECURITY.md +71 -0
  12. package/START_HERE.md +200 -0
  13. package/assets/metadata/assets.json +5 -0
  14. package/assets/metadata/embeddings.json +5 -0
  15. package/install.sh +64 -0
  16. package/package.json +58 -4
  17. package/src/animation/animation-system.js +229 -0
  18. package/src/asset-library/README.md +188 -0
  19. package/src/asset-library/asset-library.js +639 -0
  20. package/src/audio-mixer/mixer.js +187 -0
  21. package/src/cli/doctor.js +173 -0
  22. package/src/cli/make.js +655 -0
  23. package/src/cli/setup-keys.js +392 -0
  24. package/src/config/load-config.js +72 -0
  25. package/src/fact-checker/checker.js +384 -0
  26. package/src/fact-checker/review-gate.js +306 -0
  27. package/src/fact-checker/validation-set.js +1078 -0
  28. package/src/mcp-server/server.js +1299 -0
  29. package/src/mcp-server/stdio.js +87 -0
  30. package/src/pipeline/artifact-manifest.js +323 -0
  31. package/src/pipeline/artifact-retention.js +189 -0
  32. package/src/pipeline/external-adapter.js +366 -0
  33. package/src/pipeline/providers.js +853 -0
  34. package/src/pipeline/retry-policy.js +134 -0
  35. package/src/pipeline/state-machine.js +297 -0
  36. package/src/pipeline/telemetry.js +162 -0
  37. package/src/quality-gate/gate.js +229 -0
  38. package/src/schema/scene.schema.json +101 -0
  39. package/src/shorts-extractor/extractor.js +343 -0
  40. package/src/utils/log-redaction.js +274 -0
  41. package/src/video-renderer/renderer.js +364 -0
  42. package/src/worker/server-render-free.js +420 -0
  43. package/src/worker/server.js +328 -0
  44. package/src/workflows/assets.js +131 -0
  45. package/src/workflows/ingest.js +299 -0
  46. package/src/workflows/packaging.js +156 -0
  47. package/src/workflows/publishing.js +206 -0
  48. package/src/workflows/render.js +102 -0
  49. package/src/workflows/research.js +151 -0
  50. package/src/workflows/script.js +210 -0
  51. package/src/workflows/shorts.js +113 -0
package/install.sh ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # Install the video-pipeline skill into your Claude/Bob skills folder.
3
+ #
4
+ # ./install.sh copy into ~/.claude/skills (every project)
5
+ # ./install.sh --project copy into ./.claude/skills (this project only)
6
+ # ./install.sh --link symlink instead of copy (edits to repo are live)
7
+ # ./install.sh --bob copy into ~/.bob/skills (IBM Bob global)
8
+ #
9
+ # An existing skill with the same name is moved aside to <name>.bak.<ts>.
10
+ set -euo pipefail
11
+
12
+ HERE="$(cd "$(dirname "$0")" && pwd)"
13
+ SRC="$HERE/plugins/video-pipeline/skills"
14
+ DEST="$HOME/.claude/skills"
15
+ MODE=copy
16
+
17
+ for a in "$@"; do
18
+ case "$a" in
19
+ --link) MODE=link ;;
20
+ --project) DEST="$PWD/.claude/skills" ;;
21
+ --bob) DEST="$HOME/.bob/skills" ;;
22
+ -h|--help) sed -n 2,10p "$0"; exit 0 ;;
23
+ *) echo "unknown option: $a"; exit 1 ;;
24
+ esac
25
+ done
26
+
27
+ mkdir -p "$DEST"
28
+
29
+ for dir in "$SRC"/*; do
30
+ name=$(basename "$dir")
31
+ target="$DEST/$name"
32
+ if [ -e "$target" ] || [ -L "$target" ]; then
33
+ mv "$target" "$target.bak.$(date +%Y%m%d%H%M%S)"
34
+ echo " moved existing $name aside"
35
+ fi
36
+ if [ "$MODE" = link ]; then
37
+ ln -s "$dir" "$target"
38
+ else
39
+ cp -R "$dir" "$target"
40
+ fi
41
+ echo " installed $name → $target ($MODE)"
42
+ done
43
+
44
+ echo
45
+ echo "Dependencies (must be available in PATH):"
46
+ for t in ffmpeg ffprobe node npm; do
47
+ printf " %-10s %s\n" "$t" "$(command -v "$t" 2>/dev/null || echo 'MISSING — install it')"
48
+ done
49
+ # Optional: edge-tts for free TTS
50
+ if command -v edge-tts >/dev/null 2>&1; then
51
+ echo " edge-tts $(command -v edge-tts) (free TTS, no key)"
52
+ else
53
+ echo " edge-tts not found — run: npm install -g edge-tts"
54
+ fi
55
+
56
+ echo
57
+ echo "Next steps:"
58
+ echo " 1. cp docs/env.template .env"
59
+ echo " 2. Edit .env with your API keys (run 'npm run setup' for interactive wizard)"
60
+ echo " 3. Deploy Cloudflare Remote MCP (recommended): cd cloudflare-mcp && wrangler deploy"
61
+ echo " OR start local HTTP MCP: node src/mcp-server/server.js"
62
+ echo " 4. Register the MCP endpoint in your IDE:"
63
+ echo " Codex / opencode / Cursor / IBM Bob / Claude Code / Windsurf — see README.md"
64
+ echo " 5. Restart your IDE to load the skill."
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@amg555/frameforge-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.3.1",
4
+ "description": "FrameForge — AI video production engine for agentic IDEs. npx @amg555/frameforge-mcp. FFmpeg + TTS run on your machine. 17 MCP tools.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "src/mcp-server/server.js",
9
+ "bin": {
10
+ "frameforge": "src/cli/make.js",
11
+ "frameforge-mcp": "src/mcp-server/stdio.js",
12
+ "frameforge-setup": "src/cli/setup-keys.js",
13
+ "frameforge-doctor": "src/cli/doctor.js"
14
+ },
15
+ "scripts": {
16
+ "make": "node src/cli/make.js",
17
+ "setup": "node src/cli/setup-keys.js",
18
+ "setup:check": "node src/cli/setup-keys.js --check",
19
+ "doctor": "node src/cli/doctor.js",
20
+ "mcp": "node src/mcp-server/server.js",
21
+ "mcp:stdio": "node src/mcp-server/stdio.js",
22
+ "start": "node src/mcp-server/server.js",
23
+ "start:worker": "node src/worker/server.js",
24
+ "start:worker:render": "node src/worker/server-render-free.js",
25
+ "start:worker:local": "cross-env RENDER_MODE=full node src/worker/server-render-free.js",
26
+ "start:local-render": "cross-env RENDER_MODE=full WORKER_PORT=3211 node src/worker/server-render-free.js",
27
+ "db:migrate": "node scripts/db-migrate.js",
28
+ "validate": "node scripts/deep-validate.js",
29
+ "test": "node --test src/pipeline/retry-policy.test.js src/pipeline/telemetry.test.js src/pipeline/artifact-manifest.test.js src/pipeline/artifact-retention.test.js src/pipeline/external-adapter.test.js src/workflows/publishing.test.js src/workflows/script.test.js src/animation/animation-system.test.js src/fact-checker/review-gate.test.js src/fact-checker/checker.test.js src/utils/log-redaction.test.js src/worker/server-render-free.test.js src/worker/server.test.js src/mcp-server/server.test.js src/quality-gate/gate.test.js src/shorts-extractor/extractor.test.js src/audio-mixer/mixer.test.js src/video-renderer/renderer.test.js plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.test.js src/integration.test.js",
30
+ "lint": "echo 'No linter configured — add eslint for production'",
31
+ "check-credentials": "node src/utils/log-redaction.js",
32
+ "generate-assets": "node scripts/generate-initial-assets.js",
33
+ "pipeline:research": "node src/workflows/research.js",
34
+ "pipeline:script": "node src/workflows/script.js"
35
+ },
36
+ "engines": {
37
+ "node": ">=18.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "playwright": "^1.64.1"
41
+ },
42
+ "keywords": [
43
+ "frameforge",
44
+ "video",
45
+ "ai",
46
+ "mcp",
47
+ "cloudflare",
48
+ "automation",
49
+ "ibm-bob",
50
+ "cursor",
51
+ "windsurf",
52
+ "codex",
53
+ "opencode"
54
+ ],
55
+ "license": "MIT",
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/AMG555/FrameForge.git"
59
+ }
60
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Animation System — Task #11
3
+ *
4
+ * Narrative-driven camera movement presets for the video renderer.
5
+ * Every preset is a pure function: (scene metadata) → FFmpeg filter string.
6
+ *
7
+ * Camera hints map directly to the scene.camera_hint field in schema v1.0.0:
8
+ * slow_zoom_in | slow_zoom_out | pan_left | pan_right
9
+ * tilt_up | tilt_down | ken_burns | static | auto
10
+ *
11
+ * Transition types (used between scenes in the concat filter):
12
+ * cut | fade | dissolve | wipe_left | wipe_right
13
+ *
14
+ * All zoom values stay within ±1.12x to prevent quality loss (safe_zone = 1.12).
15
+ * All coordinates use FFmpeg expression notation (evaluated per-frame by zoompan).
16
+ *
17
+ * Usage:
18
+ * const { buildCameraFilter, buildTransitionFilter, selectCameraHint } = require('./animation-system');
19
+ * const vf = buildCameraFilter('slow_zoom_in', { duration: 6 });
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ // ── Constants ────────────────────────────────────────────────────────────────
25
+
26
+ const FPS = 24; // target frames per second
27
+ const MAX_ZOOM = 1.12; // maximum safe zoom factor
28
+ const BASE_ZOOM = 1.0; // starting zoom (no zoom)
29
+ const DEFAULT_W = 1920;
30
+ const DEFAULT_H = 1080;
31
+
32
+ // Per-frame zoom step for a smooth motion over ~8s at 24fps
33
+ // 1.12 - 1.00 = 0.12 over (8 * 24) = 192 frames → 0.000625/frame
34
+ // Round to 0.0007 for slightly snappier feel
35
+ const ZOOM_STEP_IN = 0.0007;
36
+ const ZOOM_STEP_OUT = 0.0007;
37
+
38
+ // ── Camera filter builders ───────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Build an FFmpeg -vf filter string for the given camera preset.
42
+ *
43
+ * @param {string} hint - camera_hint value from scene schema
44
+ * @param {{ duration?: number, focalX?: number, focalY?: number, w?: number, h?: number }} opts
45
+ * duration scene duration in seconds (used to calibrate speed)
46
+ * focalX/Y focal point override (0-1 relative, default 0.5/0.35)
47
+ * w/h output resolution (default 1920×1080)
48
+ * @returns {string} FFmpeg filter chain (ready for -vf)
49
+ */
50
+ function buildCameraFilter(hint, { duration = 6, focalX = 0.5, focalY = 0.35, w = DEFAULT_W, h = DEFAULT_H } = {}) {
51
+ const frames = Math.max(48, Math.round(duration * FPS));
52
+ const zoomBase = `${w}x${h}`;
53
+ // Focal point in pixels
54
+ const px = Math.round(focalX * w);
55
+ const py = Math.round(focalY * h);
56
+
57
+ switch ((hint || 'auto').toLowerCase()) {
58
+ case 'slow_zoom_in':
59
+ return _zoomIn(frames, ZOOM_STEP_IN, zoomBase);
60
+ case 'slow_zoom_out':
61
+ return _zoomOut(frames, ZOOM_STEP_OUT, zoomBase);
62
+ case 'zoom_in':
63
+ return _zoomIn(frames, 0.0012, zoomBase);
64
+ case 'zoom_out':
65
+ return _zoomOut(frames, 0.0012, zoomBase);
66
+ case 'pan_left':
67
+ return _pan('left', frames, zoomBase);
68
+ case 'pan_right':
69
+ return _pan('right', frames, zoomBase);
70
+ case 'tilt_up':
71
+ return _tilt('up', frames, zoomBase);
72
+ case 'tilt_down':
73
+ return _tilt('down', frames, zoomBase);
74
+ case 'ken_burns':
75
+ return _kenBurns(frames, px, py, w, h, zoomBase);
76
+ case 'static':
77
+ return _static(w, h);
78
+ case 'auto':
79
+ default:
80
+ // Deterministic selection based on focal-point quadrant
81
+ return _autoSelect(focalX, focalY, frames, w, h, zoomBase);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Build the FFmpeg overlay filter for a cross-dissolve transition between
87
+ * two pre-rendered scene clips. Used in complex_filter assembly.
88
+ *
89
+ * Note: hard cuts require no filter (FFmpeg concat demuxer default).
90
+ * Fades/dissolves need xfade filter (FFmpeg ≥4.3).
91
+ *
92
+ * @param {'cut'|'fade'|'dissolve'|'wipe_left'|'wipe_right'} type
93
+ * @param {number} offset time offset (seconds) at which the transition starts
94
+ * @param {number} duration transition duration in seconds (default 0.5)
95
+ * @returns {{ xfadeArgs: string[], isCut: boolean }}
96
+ */
97
+ function buildTransitionFilter(type, offset, duration = 0.5) {
98
+ if (type === 'cut' || !type) {
99
+ return { isCut: true, xfadeArgs: [] };
100
+ }
101
+ const xfadeType = {
102
+ fade: 'fade',
103
+ dissolve: 'dissolve',
104
+ wipe_left: 'wipeleft',
105
+ wipe_right: 'wiperight',
106
+ }[type] || 'fade';
107
+
108
+ // xfade filter args (applied in filtergraph)
109
+ return {
110
+ isCut: false,
111
+ xfadeArgs: [`transition=${xfadeType}`, `offset=${offset.toFixed(3)}`, `duration=${duration}`],
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Select an appropriate camera hint for a scene when none is specified
117
+ * by the LLM. Alternates between zoom_in / zoom_out / pan to avoid
118
+ * monotony.
119
+ *
120
+ * @param {number} sceneIndex 0-based scene index
121
+ * @param {{ focalX?: number, focalY?: number }} focal
122
+ * @returns {string}
123
+ */
124
+ function selectCameraHint(sceneIndex, { focalX = 0.5, focalY = 0.35 } = {}) {
125
+ const rotation = ['slow_zoom_in', 'pan_right', 'slow_zoom_out', 'pan_left', 'tilt_up', 'ken_burns'];
126
+ return rotation[sceneIndex % rotation.length];
127
+ }
128
+
129
+ // ── Private filter implementations ───────────────────────────────────────────
130
+
131
+ function _zoomIn(frames, step = ZOOM_STEP_IN, zoomBase = `${DEFAULT_W}x${DEFAULT_H}`) {
132
+ const maxZ = MAX_ZOOM.toFixed(4);
133
+ const rangeStep = frames > 0 ? (MAX_ZOOM - BASE_ZOOM) / frames : step;
134
+ const s = Math.min(rangeStep, step * 2).toFixed(6);
135
+ return (
136
+ `zoompan=` +
137
+ `z='min(zoom+${s},${maxZ})':` +
138
+ `x='iw/2-(iw/zoom/2)':` +
139
+ `y='ih/2-(ih/zoom/2)':` +
140
+ `d=1:s=${zoomBase}:fps=${FPS}`
141
+ );
142
+ }
143
+
144
+ function _zoomOut(frames, step = ZOOM_STEP_OUT, zoomBase = `${DEFAULT_W}x${DEFAULT_H}`) {
145
+ const maxZ = MAX_ZOOM.toFixed(4);
146
+ const rangeStep = frames > 0 ? (MAX_ZOOM - BASE_ZOOM) / frames : step;
147
+ const s = Math.min(rangeStep, step * 2).toFixed(6);
148
+ return (
149
+ `zoompan=` +
150
+ `z='if(lte(zoom,1.0),${maxZ},max(zoom-${s},1.0))':` +
151
+ `x='iw/2-(iw/zoom/2)':` +
152
+ `y='ih/2-(ih/zoom/2)':` +
153
+ `d=1:s=${zoomBase}:fps=${FPS}`
154
+ );
155
+ }
156
+
157
+ function _pan(direction, frames, zoomBase = `${DEFAULT_W}x${DEFAULT_H}`) {
158
+ if (direction === 'left') {
159
+ return (
160
+ `zoompan=` +
161
+ `z='1.06':` +
162
+ `x='if(gte(on,1),x-0.6,iw*0.04)':` +
163
+ `y='ih*0.06':` +
164
+ `d=1:s=${zoomBase}:fps=${FPS}`
165
+ );
166
+ }
167
+ return (
168
+ `zoompan=` +
169
+ `z='1.06':` +
170
+ `x='if(gte(on,1),x+0.6,iw*0.02)':` +
171
+ `y='ih*0.06':` +
172
+ `d=1:s=${zoomBase}:fps=${FPS}`
173
+ );
174
+ }
175
+
176
+ function _tilt(direction, frames, zoomBase = `${DEFAULT_W}x${DEFAULT_H}`) {
177
+ if (direction === 'up') {
178
+ return (
179
+ `zoompan=` +
180
+ `z='1.06':` +
181
+ `x='iw*0.06':` +
182
+ `y='if(gte(on,1),y-0.4,ih*0.12)':` +
183
+ `d=1:s=${zoomBase}:fps=${FPS}`
184
+ );
185
+ }
186
+ return (
187
+ `zoompan=` +
188
+ `z='1.06':` +
189
+ `x='iw*0.06':` +
190
+ `y='if(gte(on,1),y+0.4,ih*0.02)':` +
191
+ `d=1:s=${zoomBase}:fps=${FPS}`
192
+ );
193
+ }
194
+
195
+ function _kenBurns(frames, focalPx, focalPy, w = DEFAULT_W, h = DEFAULT_H, zoomBase = `${DEFAULT_W}x${DEFAULT_H}`) {
196
+ const maxZ = MAX_ZOOM.toFixed(4);
197
+ const startX = Math.max(0, focalPx - w / 2);
198
+ const startY = Math.max(0, focalPy - h / 2);
199
+ return (
200
+ `zoompan=` +
201
+ `z='max(${maxZ}-0.0006*on,1.0)':` +
202
+ `x='if(eq(on,1),${startX},x+(iw/2-(iw/zoom/2)-x)*0.02)':` +
203
+ `y='if(eq(on,1),${startY},y+(ih/2-(ih/zoom/2)-y)*0.02)':` +
204
+ `d=1:s=${zoomBase}:fps=${FPS}`
205
+ );
206
+ }
207
+
208
+ function _static(w = DEFAULT_W, h = DEFAULT_H) {
209
+ return `scale=${w}:${h}:force_original_aspect_ratio=increase:flags=lanczos,crop=${w}:${h}`;
210
+ }
211
+
212
+ function _autoSelect(focalX, focalY, frames, w = DEFAULT_W, h = DEFAULT_H, zoomBase = `${DEFAULT_W}x${DEFAULT_H}`) {
213
+ if (focalY < 0.4 && focalX < 0.5) return _zoomIn(frames, ZOOM_STEP_IN, zoomBase);
214
+ if (focalY < 0.4 && focalX >= 0.5) return _kenBurns(frames, Math.round(focalX * w), Math.round(focalY * h), w, h, zoomBase);
215
+ if (focalX < 0.4) return _pan('right', frames, zoomBase);
216
+ if (focalX > 0.6) return _pan('left', frames, zoomBase);
217
+ return _zoomOut(frames, ZOOM_STEP_OUT, zoomBase);
218
+ }
219
+
220
+ // ── Exports ───────────────────────────────────────────────────────────────────
221
+
222
+ module.exports = {
223
+ buildCameraFilter,
224
+ buildTransitionFilter,
225
+ selectCameraHint,
226
+ // constants exposed for testing
227
+ FPS,
228
+ MAX_ZOOM,
229
+ };
@@ -0,0 +1,188 @@
1
+ # Asset Library System
2
+
3
+ Composable asset management for consistent video production.
4
+
5
+ ## Architecture
6
+
7
+ ### Composable Asset System
8
+
9
+ Instead of pre-generating all combinations (2 chars × 4 poses × 6 emotions = 48 combinations), we store:
10
+
11
+ - **Character identities:** 2 base characters (ancient_man, modern_man)
12
+ - **Pose variants:** 4 poses per character (standing, running, sitting, jumping)
13
+ - **Emotion overlays:** 6 emotions per character (neutral, happy, angry, sad, surprised, determined)
14
+ - **Props:** 15 reusable items (spear, phone, fire, etc.)
15
+ - **Backgrounds:** 10 scenes (forest, cave, city, etc.)
16
+ - **Effects:** 10 SVG overlays (arrow, question, etc.)
17
+
18
+ **Total:** ~47 composable assets vs 67-83 pre-generated combinations
19
+
20
+ ### Runtime Composition
21
+
22
+ ```
23
+ Request: character=ancient_man, pose=running, emotion=determined
24
+
25
+ Composition:
26
+ 1. Load identity: ancient_man_identity.png
27
+ 2. Load pose: ancient_man/poses/running.png
28
+ 3. Load emotion: ancient_man/emotions/determined.png
29
+ 4. Composite layers → final image
30
+ ```
31
+
32
+ ## Directory Structure
33
+
34
+ ```
35
+ assets/
36
+ ├── characters/
37
+ │ ├── ancient_man/
38
+ │ │ ├── identity_v1.png # Base character
39
+ │ │ ├── schema_version.json # Metadata
40
+ │ │ ├── poses/
41
+ │ │ │ ├── standing.png
42
+ │ │ │ ├── running.png
43
+ │ │ │ ├── sitting.png
44
+ │ │ │ └── jumping.png
45
+ │ │ └── emotions/
46
+ │ │ ├── neutral.png
47
+ │ │ ├── happy.png
48
+ │ │ ├── angry.png
49
+ │ │ ├── sad.png
50
+ │ │ ├── surprised.png
51
+ │ │ └── determined.png
52
+ │ └── modern_man/
53
+ │ ├── identity_v1.png
54
+ │ ├── schema_version.json
55
+ │ ├── poses/
56
+ │ └── emotions/
57
+ ├── props/
58
+ │ ├── spear.png
59
+ │ ├── phone.png
60
+ │ ├── fire.png
61
+ │ └── ... (15 total)
62
+ ├── backgrounds/
63
+ │ ├── forest.png
64
+ │ ├── cave.png
65
+ │ ├── city.png
66
+ │ └── ... (10 total)
67
+ ├── effects/
68
+ │ ├── arrow.svg
69
+ │ ├── question.svg
70
+ │ └── ... (10 total)
71
+ └── metadata/
72
+ ├── assets.json # Asset registry
73
+ └── embeddings.json # Semantic search cache
74
+ ```
75
+
76
+ ## Asset Metadata Schema
77
+
78
+ ```json
79
+ {
80
+ "asset_id": "ancient_man_identity_v1",
81
+ "asset_type": "character_identity",
82
+ "character_id": "ancient_man",
83
+ "pose": null,
84
+ "emotion": null,
85
+ "visual_description": "Caveman with wild hair, tattered animal skin clothing",
86
+ "file_path": "assets/characters/ancient_man/identity_v1.png",
87
+ "style_parameters": {
88
+ "ink_style": "clean_line",
89
+ "color_palette": ["#000000", "#FFFFFF", "#8B4513"],
90
+ "line_weight": 3,
91
+ "resolution": "1920x1080"
92
+ },
93
+ "metadata": {
94
+ "creation_date": "2026-09-09T10:00:00Z",
95
+ "usage_count": 42,
96
+ "variants": ["pose:running", "emotion:determined"]
97
+ },
98
+ "schema_version": "1.0.0"
99
+ }
100
+ ```
101
+
102
+ ## Matching Algorithm
103
+
104
+ ### 1. Metadata Filtering (Primary)
105
+
106
+ ```sql
107
+ SELECT * FROM assets
108
+ WHERE asset_type = 'character_identity'
109
+ AND character_id = 'ancient_man'
110
+ AND (pose = 'running' OR pose IS NULL)
111
+ AND (emotion = 'determined' OR emotion IS NULL);
112
+ ```
113
+
114
+ Fast metadata lookup in PostgreSQL for exact matches.
115
+
116
+ ### 2. Semantic Fallback (Secondary)
117
+
118
+ If no metadata match:
119
+ 1. Compute embedding of visual description
120
+ 2. Search embeddings with cosine similarity
121
+ 3. Return if similarity > 0.85
122
+ 4. Otherwise, generate new asset
123
+
124
+ ## Style Consistency
125
+
126
+ All generated assets must match:
127
+
128
+ - **Ink style:** clean_line (Simple History / Doodle style)
129
+ - **Color palette:** Limited colors (black, white, earth tones)
130
+ - **Line weight:** 2-4px (consistent thickness)
131
+ - **Resolution:** 1920x1080 (landscape master)
132
+ - **Background:** White or transparent
133
+ - **Character scale:** Consistent proportions
134
+
135
+ ## Asset Generation
136
+
137
+ When no match found:
138
+
139
+ ```javascript
140
+ const prompt = `
141
+ Simple History doodle style illustration.
142
+ Character: ${character_id}
143
+ Pose: ${pose}
144
+ Emotion: ${emotion}
145
+ Style: Thick black ink lines, clean line art, white background.
146
+ No photorealism. Exaggerated facial expression.
147
+ Single character only.
148
+ 1920x1080 landscape.
149
+ `;
150
+
151
+ const imageUrl = await pollinations.generate({
152
+ prompt,
153
+ width: 1920,
154
+ height: 1080,
155
+ seed: deterministicSeed
156
+ });
157
+ ```
158
+
159
+ ## API
160
+
161
+ See `asset-library.js` for implementation:
162
+
163
+ - `initialize()` - Load asset registry
164
+ - `findAsset(query)` - Search with metadata + semantic fallback
165
+ - `generateAsset(description)` - Create new asset
166
+ - `validateAsset(asset)` - Check style consistency
167
+ - `composeCharacter(identity, pose, emotion)` - Layer composition
168
+
169
+ ## Schema Versioning
170
+
171
+ All assets track `schema_version: "1.0.0"` for evolution:
172
+
173
+ - **1.0.0:** Initial composable system
174
+ - **1.1.0:** (Future) Add animation keyframes
175
+ - **2.0.0:** (Future) 3D pose variants
176
+
177
+ ## Performance
178
+
179
+ - **Metadata lookup:** <10ms (PostgreSQL indexed)
180
+ - **Semantic search:** <200ms (cached embeddings)
181
+ - **Asset composition:** <500ms (image layering)
182
+ - **Asset generation:** 5-15s (Pollinations API)
183
+
184
+ ## Storage
185
+
186
+ - **Per identity:** ~2MB (base + poses + emotions)
187
+ - **Total library:** ~100MB for 47 composable assets
188
+ - **Cache efficiency:** 70%+ asset reuse across videos