From b4f7ec4eee03409b4dfcd1297ee41b095c9dcf59 Mon Sep 17 00:00:00 2001 From: calesthio Date: Sat, 18 Apr 2026 18:37:00 -0700 Subject: [PATCH] hyperframes: add HTML/CSS/GSAP as a parallel composition runtime Separates creative grammar (renderer_family) from technical engine (render_runtime) so HyperFrames can stand alongside Remotion as a first-class runtime instead of masquerading as a Remotion sub-case. Locks runtime choice at proposal stage and enforces it end-to-end: the schemas require it, video_compose routes by it, the reviewer fails closed on silent swaps, and a parametrized contract test walks every pipeline manifest to ensure each planning-stage skill explains the conversation to the user. Adds hyperframes_compose (scaffold/lint/ validate/render/doctor/add_block), a playbook -> CSS style bridge, and vendored HyperFrames Layer 3 skills from commit d291358, pinned via PROVENANCE.md for future re-sync. Final_review now records render_runtime_used and runtime_swap_detected so compose lies are catchable after the fact. --- .agents/skills/hyperframes-cli/SKILL.md | 136 ++ .agents/skills/hyperframes-registry/SKILL.md | 104 ++ .../examples/add-block.md | 51 + .../examples/add-component.md | 73 + .../references/demo-html-pattern.md | 54 + .../references/discovery.md | 53 + .../references/install-locations.md | 45 + .../references/wiring-blocks.md | 91 ++ .../references/wiring-components.md | 77 ++ .agents/skills/hyperframes/PROVENANCE.md | 59 + .agents/skills/hyperframes/SKILL.md | 347 +++++ .agents/skills/hyperframes/data-in-motion.md | 19 + .agents/skills/hyperframes/house-style.md | 71 + .../hyperframes/palettes/bold-energetic.md | 14 + .../hyperframes/palettes/clean-corporate.md | 14 + .../hyperframes/palettes/dark-premium.md | 14 + .../skills/hyperframes/palettes/jewel-rich.md | 14 + .../skills/hyperframes/palettes/monochrome.md | 14 + .../hyperframes/palettes/nature-earth.md | 14 + .../hyperframes/palettes/neon-electric.md | 14 + .../hyperframes/palettes/pastel-soft.md | 14 + .../hyperframes/palettes/warm-editorial.md | 14 + .agents/skills/hyperframes/patterns.md | 118 ++ .../hyperframes/references/audio-reactive.md | 76 ++ .../skills/hyperframes/references/captions.md | 132 ++ .../hyperframes/references/css-patterns.md | 373 ++++++ .../references/dynamic-techniques.md | 90 ++ .../references/motion-principles.md | 69 + .../references/transcript-guide.md | 151 +++ .../hyperframes/references/transitions.md | 112 ++ .../references/transitions/catalog.md | 117 ++ .../references/transitions/css-3d.md | 12 + .../references/transitions/css-blur.md | 51 + .../references/transitions/css-cover.md | 43 + .../references/transitions/css-destruction.md | 95 ++ .../references/transitions/css-dissolve.md | 66 + .../references/transitions/css-distortion.md | 45 + .../references/transitions/css-grid.md | 10 + .../references/transitions/css-light.md | 49 + .../references/transitions/css-mechanical.md | 30 + .../references/transitions/css-other.md | 25 + .../references/transitions/css-push.md | 41 + .../references/transitions/css-radial.md | 37 + .../references/transitions/css-scale.md | 24 + .agents/skills/hyperframes/references/tts.md | 56 + .../hyperframes/references/typography.md | 175 +++ .../hyperframes/scripts/animation-map.mjs | 596 +++++++++ .../hyperframes/scripts/contrast-report.mjs | 335 +++++ .agents/skills/hyperframes/visual-styles.md | 211 +++ .../skills/website-to-hyperframes/SKILL.md | 121 ++ .../references/step-1-capture.md | 66 + .../references/step-2-design.md | 128 ++ .../references/step-3-script.md | 96 ++ .../references/step-4-storyboard.md | 222 +++ .../references/step-5-vo.md | 40 + .../references/step-6-build.md | 162 +++ .../references/step-7-validate.md | 109 ++ .../references/techniques.md | 341 +++++ AGENT_GUIDE.md | 78 +- Makefile | 20 +- PROJECT_CONTEXT.md | 5 + PROMPT_GALLERY.md | 32 + README.md | 10 +- docs/ARCHITECTURE.md | 25 +- lib/hyperframes_style_bridge.py | 194 +++ lib/slideshow_risk.py | 10 + pipeline_defs/animated-explainer.yaml | 3 + pipeline_defs/animation.yaml | 4 + pipeline_defs/cinematic.yaml | 3 + pipeline_defs/documentary-montage.yaml | 1 + pipeline_defs/hybrid.yaml | 3 + pipeline_defs/screen-demo.yaml | 3 + render_demo.py | 12 + schemas/artifacts/decision_log.schema.json | 1 + schemas/artifacts/edit_decisions.schema.json | 9 +- schemas/artifacts/final_review.schema.json | 15 +- schemas/artifacts/proposal_packet.schema.json | 9 +- skills/INDEX.md | 3 +- skills/core/hyperframes.md | 324 +++++ skills/creative/animation-pipeline.md | 23 +- skills/meta/animation-runtime-selector.md | 59 +- skills/meta/onboarding.md | 30 +- skills/meta/reviewer.md | 14 +- .../pipelines/animation/compose-director.md | 16 + .../pipelines/animation/proposal-director.md | 120 +- .../avatar-spokesperson/compose-director.md | 8 + .../avatar-spokesperson/idea-director.md | 6 + .../pipelines/cinematic/compose-director.md | 12 + .../pipelines/cinematic/proposal-director.md | 25 + .../clip-factory/compose-director.md | 8 + .../pipelines/clip-factory/idea-director.md | 6 + .../documentary-montage/compose-director.md | 8 + .../documentary-montage/idea-director.md | 6 + .../pipelines/explainer/compose-director.md | 12 + .../pipelines/explainer/proposal-director.md | 22 + skills/pipelines/hybrid/compose-director.md | 12 + skills/pipelines/hybrid/idea-director.md | 13 + .../localization-dub/compose-director.md | 8 + .../localization-dub/idea-director.md | 6 + .../podcast-repurpose/compose-director.md | 8 + .../podcast-repurpose/idea-director.md | 6 + .../pipelines/screen-demo/compose-director.md | 13 + skills/pipelines/screen-demo/idea-director.md | 12 + .../talking-head/compose-director.md | 8 + .../pipelines/talking-head/idea-director.md | 6 + tests/contracts/test_phase0_contracts.py | 1 + .../test_runtime_presentation_contract.py | 191 +++ tests/eval/bench_runner.py | 5 +- tests/qa/test_08_end_to_end.py | 1 + tests/qa/test_09_hyperframes_compose.py | 189 +++ tests/tools/test_documentary_governance.py | 59 + tests/tools/test_hyperframes_compose.py | 1014 ++++++++++++++ tools/analysis/composition_validator.py | 74 +- tools/graphics/image_gen.py | 10 + tools/tool_registry.py | 156 +++ tools/video/hyperframes_compose.py | 1186 +++++++++++++++++ tools/video/remotion_caption_burn.py | 18 +- tools/video/video_compose.py | 389 +++++- 118 files changed, 10128 insertions(+), 126 deletions(-) create mode 100644 .agents/skills/hyperframes-cli/SKILL.md create mode 100644 .agents/skills/hyperframes-registry/SKILL.md create mode 100644 .agents/skills/hyperframes-registry/examples/add-block.md create mode 100644 .agents/skills/hyperframes-registry/examples/add-component.md create mode 100644 .agents/skills/hyperframes-registry/references/demo-html-pattern.md create mode 100644 .agents/skills/hyperframes-registry/references/discovery.md create mode 100644 .agents/skills/hyperframes-registry/references/install-locations.md create mode 100644 .agents/skills/hyperframes-registry/references/wiring-blocks.md create mode 100644 .agents/skills/hyperframes-registry/references/wiring-components.md create mode 100644 .agents/skills/hyperframes/PROVENANCE.md create mode 100644 .agents/skills/hyperframes/SKILL.md create mode 100644 .agents/skills/hyperframes/data-in-motion.md create mode 100644 .agents/skills/hyperframes/house-style.md create mode 100644 .agents/skills/hyperframes/palettes/bold-energetic.md create mode 100644 .agents/skills/hyperframes/palettes/clean-corporate.md create mode 100644 .agents/skills/hyperframes/palettes/dark-premium.md create mode 100644 .agents/skills/hyperframes/palettes/jewel-rich.md create mode 100644 .agents/skills/hyperframes/palettes/monochrome.md create mode 100644 .agents/skills/hyperframes/palettes/nature-earth.md create mode 100644 .agents/skills/hyperframes/palettes/neon-electric.md create mode 100644 .agents/skills/hyperframes/palettes/pastel-soft.md create mode 100644 .agents/skills/hyperframes/palettes/warm-editorial.md create mode 100644 .agents/skills/hyperframes/patterns.md create mode 100644 .agents/skills/hyperframes/references/audio-reactive.md create mode 100644 .agents/skills/hyperframes/references/captions.md create mode 100644 .agents/skills/hyperframes/references/css-patterns.md create mode 100644 .agents/skills/hyperframes/references/dynamic-techniques.md create mode 100644 .agents/skills/hyperframes/references/motion-principles.md create mode 100644 .agents/skills/hyperframes/references/transcript-guide.md create mode 100644 .agents/skills/hyperframes/references/transitions.md create mode 100644 .agents/skills/hyperframes/references/transitions/catalog.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-3d.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-blur.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-cover.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-destruction.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-dissolve.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-distortion.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-grid.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-light.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-mechanical.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-other.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-push.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-radial.md create mode 100644 .agents/skills/hyperframes/references/transitions/css-scale.md create mode 100644 .agents/skills/hyperframes/references/tts.md create mode 100644 .agents/skills/hyperframes/references/typography.md create mode 100644 .agents/skills/hyperframes/scripts/animation-map.mjs create mode 100644 .agents/skills/hyperframes/scripts/contrast-report.mjs create mode 100644 .agents/skills/hyperframes/visual-styles.md create mode 100644 .agents/skills/website-to-hyperframes/SKILL.md create mode 100644 .agents/skills/website-to-hyperframes/references/step-1-capture.md create mode 100644 .agents/skills/website-to-hyperframes/references/step-2-design.md create mode 100644 .agents/skills/website-to-hyperframes/references/step-3-script.md create mode 100644 .agents/skills/website-to-hyperframes/references/step-4-storyboard.md create mode 100644 .agents/skills/website-to-hyperframes/references/step-5-vo.md create mode 100644 .agents/skills/website-to-hyperframes/references/step-6-build.md create mode 100644 .agents/skills/website-to-hyperframes/references/step-7-validate.md create mode 100644 .agents/skills/website-to-hyperframes/references/techniques.md create mode 100644 lib/hyperframes_style_bridge.py create mode 100644 skills/core/hyperframes.md create mode 100644 tests/contracts/test_runtime_presentation_contract.py create mode 100644 tests/qa/test_09_hyperframes_compose.py create mode 100644 tests/tools/test_hyperframes_compose.py create mode 100644 tools/video/hyperframes_compose.py diff --git a/.agents/skills/hyperframes-cli/SKILL.md b/.agents/skills/hyperframes-cli/SKILL.md new file mode 100644 index 0000000..d55a5ae --- /dev/null +++ b/.agents/skills/hyperframes-cli/SKILL.md @@ -0,0 +1,136 @@ +--- +name: hyperframes-cli +description: HyperFrames CLI tool — hyperframes init, lint, validate, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting or validating compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment. +--- + + + +# HyperFrames CLI + +Everything runs through `npx hyperframes`. Requires Node.js >= 22 and FFmpeg. + +## Workflow + +1. **Scaffold** — `npx hyperframes init my-video` +2. **Write** — author HTML composition (see the `hyperframes` skill) +3. **Lint** — `npx hyperframes lint` (static checks) +4. **Validate** — `npx hyperframes validate` (browser-based runtime checks + contrast audit) +5. **Preview** — `npx hyperframes preview` +6. **Render** — `npx hyperframes render` + +Lint catches static issues (missing `data-composition-id`, overlapping tracks, unregistered timelines). Validate catches runtime/visual issues by seeking into the paused composition in a real browser (contrast, broken overlays, missing assets). Run both before render. + +## Validation + +```bash +npx hyperframes validate # full validation (static + contrast + contract) +npx hyperframes validate --no-contrast # skip WCAG contrast audit when iterating fast +npx hyperframes validate --json # machine-readable output +``` + +Validation seeks to several timestamps in the paused composition, screenshots the page, samples pixels behind every text element to compute WCAG contrast ratios, and verifies runtime contracts (`window.__timelines` registration, `class="clip"` presence, valid `data-*` attributes). Run it before every render — it's cheap and catches issues lint cannot see. + +## Scaffolding + +```bash +npx hyperframes init my-video # interactive wizard +npx hyperframes init my-video --example warm-grain # pick an example +npx hyperframes init my-video --video clip.mp4 # with video file +npx hyperframes init my-video --audio track.mp3 # with audio file +npx hyperframes init my-video --non-interactive # skip prompts (CI/agents) +``` + +Templates: `blank`, `warm-grain`, `play-mode`, `swiss-grid`, `vignelli`, `decision-tree`, `kinetic-type`, `product-promo`, `nyt-graph`. + +`init` creates the right file structure, copies media, transcribes audio with Whisper, and installs AI coding skills. Use it instead of creating files by hand. + +## Linting + +```bash +npx hyperframes lint # current directory +npx hyperframes lint ./my-project # specific project +npx hyperframes lint --verbose # info-level findings +npx hyperframes lint --json # machine-readable +``` + +Lints `index.html` and all files in `compositions/`. Reports errors (must fix), warnings (should fix), and info (with `--verbose`). + +## Previewing + +```bash +npx hyperframes preview # serve current directory +npx hyperframes preview --port 4567 # custom port (default 3002) +``` + +Hot-reloads on file changes. Opens the studio in your browser automatically. + +## Rendering + +```bash +npx hyperframes render # standard MP4 +npx hyperframes render --output final.mp4 # named output +npx hyperframes render --quality draft # fast iteration +npx hyperframes render --fps 60 --quality high # final delivery +npx hyperframes render --format webm # transparent WebM +npx hyperframes render --docker # byte-identical +``` + +| Flag | Options | Default | Notes | +| -------------- | --------------------- | -------------------------- | --------------------------- | +| `--output` | path | renders/name_timestamp.mp4 | Output path | +| `--fps` | 24, 30, 60 | 30 | 60fps doubles render time | +| `--quality` | draft, standard, high | standard | draft for iterating | +| `--format` | mp4, webm | mp4 | WebM supports transparency | +| `--workers` | 1-8 or auto | auto | Each spawns Chrome | +| `--docker` | flag | off | Reproducible output | +| `--gpu` | flag | off | GPU-accelerated encoding | +| `--strict` | flag | off | Fail on lint errors | +| `--strict-all` | flag | off | Fail on errors AND warnings | + +**Quality guidance:** `draft` while iterating, `standard` for review, `high` for final delivery. + +## Transcription + +```bash +npx hyperframes transcribe audio.mp3 +npx hyperframes transcribe video.mp4 --model medium.en --language en +npx hyperframes transcribe subtitles.srt # import existing +npx hyperframes transcribe subtitles.vtt +npx hyperframes transcribe openai-response.json +``` + +## Text-to-Speech + +```bash +npx hyperframes tts "Text here" --voice af_nova --output narration.wav +npx hyperframes tts script.txt --voice bf_emma +npx hyperframes tts --list # show all voices +``` + +## Troubleshooting + +```bash +npx hyperframes doctor # check environment (Chrome, FFmpeg, Node, memory) +npx hyperframes browser # manage bundled Chrome +npx hyperframes info # version and environment details +npx hyperframes upgrade # check for updates +``` + +Run `doctor` first if rendering fails. Common issues: missing FFmpeg, missing Chrome, low memory. + +## Other + +```bash +npx hyperframes compositions # list compositions in project +npx hyperframes docs # open documentation +npx hyperframes benchmark . # benchmark render performance +``` diff --git a/.agents/skills/hyperframes-registry/SKILL.md b/.agents/skills/hyperframes-registry/SKILL.md new file mode 100644 index 0000000..0056665 --- /dev/null +++ b/.agents/skills/hyperframes-registry/SKILL.md @@ -0,0 +1,104 @@ +--- +name: hyperframes-registry +description: Install and wire registry blocks and components into HyperFrames compositions. Use when running hyperframes add, installing a block or component, wiring an installed item into index.html, or working with hyperframes.json. Covers the add command, install locations, block sub-composition wiring, component snippet merging, and registry discovery. +--- + +# HyperFrames Registry + +The registry provides reusable blocks and components installable via `hyperframes add `. + +- **Blocks** — standalone sub-compositions (own dimensions, duration, timeline). Included via `data-composition-src` in a host composition. +- **Components** — effect snippets (no own dimensions). Pasted directly into a host composition's HTML. + +## When to use this skill + +- User mentions `hyperframes add`, "block", "component", or `hyperframes.json` +- Output from `hyperframes add` appears in the session (file paths, clipboard snippet) +- You need to wire an installed item into an existing composition +- You want to discover what's available in the registry + +## Quick reference + +```bash +hyperframes add data-chart # install a block +hyperframes add grain-overlay # install a component +hyperframes add shimmer-sweep --dir . # target a specific project +hyperframes add data-chart --json # machine-readable output +hyperframes add data-chart --no-clipboard # skip clipboard (CI/headless) +``` + +After install, the CLI prints which files were written and a snippet to paste into your host composition. The snippet is a starting point — you'll need to add `data-composition-id` (must match the block's internal composition ID), `data-start`, and `data-track-index` attributes when wiring blocks. + +Note: `hyperframes add` only works for blocks and components. For examples, use `hyperframes init --example ` instead. + +## Install locations + +Blocks install to `compositions/.html` by default. +Components install to `compositions/components/.html` by default. + +These paths are configurable in `hyperframes.json`: + +```json +{ + "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry", + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets" + } +} +``` + +See [install-locations.md](./references/install-locations.md) for full details. + +## Wiring blocks + +Blocks are standalone compositions — include them via `data-composition-src` in your host `index.html`: + +```html +
+``` + +Key attributes: + +- `data-composition-src` — path to the block HTML file +- `data-composition-id` — must match the block's internal ID +- `data-start` — when the block appears in the host timeline (seconds) +- `data-duration` — how long the block plays +- `data-width` / `data-height` — block canvas dimensions +- `data-track-index` — layer ordering (higher = in front) + +See [wiring-blocks.md](./references/wiring-blocks.md) for full details. + +## Wiring components + +Components are snippets — paste their HTML into your composition's markup, their CSS into your style block, and their JS into your script (if any): + +1. Read the installed file (e.g., `compositions/components/grain-overlay.html`) +2. Copy the HTML elements into your composition's `
` +3. Copy the ` + + +
+ + +
+ + + +``` + +Key conventions: + +- `data-composition-id` is `-demo` to avoid collisions +- The demo is self-contained — all CSS and JS from the snippet is inlined +- The GSAP timeline is registered on `window.__timelines` +- Duration should be long enough to showcase the effect (typically 5-8 seconds) + +## Blocks don't need demo.html + +Blocks are already standalone compositions that can be rendered directly. Only components need the demo wrapper. + +## Demos are not installed + +The `demo.html` is NOT installed by `hyperframes add` — it exists only in the registry for preview generation and as a reference. diff --git a/.agents/skills/hyperframes-registry/references/discovery.md b/.agents/skills/hyperframes-registry/references/discovery.md new file mode 100644 index 0000000..65a8d27 --- /dev/null +++ b/.agents/skills/hyperframes-registry/references/discovery.md @@ -0,0 +1,53 @@ +# Registry Discovery + +## Reading the registry manifest + +The top-level `registry.json` lists all available items: + +```bash +curl -s https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/registry.json +``` + +Each entry has `name` and `type` (`hyperframes:example`, `hyperframes:block`, or `hyperframes:component`). + +## Reading an item's manifest + +Each item has a `registry-item.json` with full metadata: + +``` +///registry-item.json +``` + +Where `` is `examples`, `blocks`, or `components`. + +## Item manifest fields + +| Field | Type | Required | Description | +| ---------------------- | -------- | -------- | ---------------------------------------------- | +| `name` | string | yes | Kebab-case identifier | +| `type` | string | yes | `hyperframes:block` or `hyperframes:component` | +| `title` | string | yes | Human-readable title | +| `description` | string | yes | One-line description | +| `tags` | string[] | no | Filter tags (e.g., `["data", "chart"]`) | +| `dimensions` | object | blocks | `{ width, height }` — blocks only | +| `duration` | number | blocks | Duration in seconds — blocks only | +| `files` | array | yes | Files to install (`path`, `target`, `type`) | +| `registryDependencies` | string[] | no | Other registry items this depends on | + +## Available items + +### Blocks + +| Name | Description | Tags | +| ------------ | ----------------------------------------------- | ------------------------------- | +| `data-chart` | Animated bar + line chart with staggered reveal | data, chart, statistics | +| `flowchart` | Decision tree with SVG connectors and cursor | diagram, flowchart, interactive | +| `logo-outro` | Cinematic logo reveal with tagline | branding, outro, logo | + +### Components + +| Name | Description | Tags | +| -------------------- | --------------------------------------- | -------------------------------- | +| `grain-overlay` | Animated film grain texture overlay | texture, grain, overlay, film | +| `shimmer-sweep` | CSS gradient light sweep for AI accents | text, shimmer, highlight, effect | +| `grid-pixelate-wipe` | Grid dissolve transition between scenes | transition, wipe, grid, pixelate | diff --git a/.agents/skills/hyperframes-registry/references/install-locations.md b/.agents/skills/hyperframes-registry/references/install-locations.md new file mode 100644 index 0000000..65e3d34 --- /dev/null +++ b/.agents/skills/hyperframes-registry/references/install-locations.md @@ -0,0 +1,45 @@ +# Install Locations + +## Default paths + +| Item type | Default install path | Configured by | +| --------- | ------------------------------------- | ----------------------------------- | +| Block | `compositions/.html` | `hyperframes.json#paths.blocks` | +| Component | `compositions/components/.html` | `hyperframes.json#paths.components` | + +## How path remapping works + +The `target` field in each item's `registry-item.json` specifies a default install path. The `add` command remaps the prefix based on `hyperframes.json#paths`: + +- Block targets starting with `compositions/` get remapped to `/` +- Component targets starting with `compositions/components/` get remapped to `/` + +## hyperframes.json + +Created automatically by `hyperframes init`. If it doesn't exist when you run `add`, the CLI creates it with defaults: + +```json +{ + "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json", + "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry", + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets" + } +} +``` + +## Custom layouts + +To install blocks into a `scenes/` directory instead of `compositions/`: + +```json +{ + "paths": { + "blocks": "scenes" + } +} +``` + +Then `hyperframes add data-chart` writes to `scenes/data-chart.html` instead of `compositions/data-chart.html`. The snippet output reflects the remapped path. diff --git a/.agents/skills/hyperframes-registry/references/wiring-blocks.md b/.agents/skills/hyperframes-registry/references/wiring-blocks.md new file mode 100644 index 0000000..957fa07 --- /dev/null +++ b/.agents/skills/hyperframes-registry/references/wiring-blocks.md @@ -0,0 +1,91 @@ +# Wiring Blocks + +Blocks are standalone compositions with their own `data-composition-id`, dimensions, duration, and GSAP timeline. Include them in a host composition using `data-composition-src` on a `
`. + +## Basic wiring + +After `hyperframes add data-chart`, wire it into your `index.html`: + +```html +
+ + + +
+
+``` + +## Required attributes + +| Attribute | Description | +| ---------------------- | -------------------------------------------------------------------- | +| `data-composition-src` | Path to the block HTML file (relative to index.html) | +| `data-composition-id` | Unique ID matching the block's internal composition ID | +| `data-start` | When the block appears in the host timeline (seconds) | +| `data-duration` | How long the block plays (seconds, at most the block's own duration) | +| `data-track-index` | Layer ordering — higher numbers render in front | +| `data-width` | Block canvas width (match the block's dimensions) | +| `data-height` | Block canvas height (match the block's dimensions) | + +## Timeline coordination + +The block's internal GSAP timeline runs independently from the host timeline. The HyperFrames runtime loads the sub-composition, finds its `window.__timelines` registration, and seeks the block in sync with the host, offset by `data-start`. You do NOT need to reference the block's timeline in your host's GSAP code. + +## Positioning blocks + +To position a block in a specific area of the screen, add CSS: + +```html +
+``` + +## Multiple blocks + +Include multiple blocks sequentially or overlapping: + +```html +
+
+
+``` diff --git a/.agents/skills/hyperframes-registry/references/wiring-components.md b/.agents/skills/hyperframes-registry/references/wiring-components.md new file mode 100644 index 0000000..2821620 --- /dev/null +++ b/.agents/skills/hyperframes-registry/references/wiring-components.md @@ -0,0 +1,77 @@ +# Wiring Components + +Components are effect snippets — HTML, CSS, and optionally JS that you merge directly into an existing composition. Unlike blocks, components have no standalone timeline; they participate in the host composition's timeline. + +## General process + +1. Run `hyperframes add ` +2. Open the installed file (e.g., `compositions/components/grain-overlay.html`) +3. Read the comment header for usage instructions +4. Copy the parts into your host composition: + - **HTML elements** — inside your `
` + - **CSS styles** — into your composition's ` + + +
+ +``` + +Load in root: `
` + +## Video and Audio + +Video must be `muted playsinline`. Audio is always a separate `
+``` + +```css +.mh-scribble-wrap { + position: relative; + display: inline; +} +.mh-scribble-text { + position: relative; + z-index: 1; +} +.mh-scribble-svg { + position: absolute; + left: 0; + bottom: -6px; + width: 100%; + height: 24px; + z-index: 0; +} +``` + +```js +// Measure path length and set initial dash state +var path = document.querySelector("#scribble-1"); +var len = path.getTotalLength(); +gsap.set(path, { strokeDasharray: len, strokeDashoffset: len }); + +// Draw the line +tl.to( + "#scribble-1", + { + strokeDashoffset: 0, + duration: 0.8, + ease: "power1.inOut", + }, + 0.7, +); +``` + +### Strikethrough Variant + +Position the SVG at `top: 50%; transform: translateY(-50%)` instead of `bottom: -6px`. + +### Wavy Path Generator + +Scale the path's viewBox width to match text width. The wave pattern `Q x1,y1 x2,y2` alternates between `y=0` and `y=24` for a natural wobble. Adjust the control points for tighter or looser waves: + +- **Tight waves**: smaller x-increments (25px per half-wave) +- **Loose waves**: larger x-increments (50px per half-wave) +- **Amplitude**: change the y range (0-24 for standard, 0-16 for subtle) + +## 5. Sketchout Mode + +Cross-hatch lines over de-emphasized text. Multiple angled lines create a "crossed out" effect. + +```html + + old price + + + + + +``` + +```css +.mh-sketchout-wrap { + position: relative; + display: inline; +} +.mh-sketchout-text { + position: relative; + z-index: 0; +} +.mh-sketchout-lines { + position: absolute; + top: 0; + left: -4px; + right: -4px; + bottom: 0; + overflow: hidden; + z-index: 1; +} +.mh-sketchout-line { + position: absolute; + display: block; + top: 50%; + left: 0; + width: 100%; + height: 2px; + background: #e53935; + transform-origin: left center; + transform: scaleX(0); +} +.mh-sketchout-fwd { + transform: scaleX(0) rotate(-12deg); +} +.mh-sketchout-bwd { + transform: scaleX(0) rotate(12deg); +} +``` + +```js +// Forward slash draws first +tl.to( + "#sketchout-1 .mh-sketchout-fwd", + { + scaleX: 1, + duration: 0.3, + ease: "power2.out", + }, + 1.0, +); + +// Backward slash follows +tl.to( + "#sketchout-1 .mh-sketchout-bwd", + { + scaleX: 1, + duration: 0.3, + ease: "power2.out", + }, + 1.15, +); +``` + +## Combining Modes in Captions + +Use mode cycling for visual variety across caption groups: + +```js +var MODES = ["highlight", "circle", "burst", "scribble"]; + +GROUPS.forEach(function (group, gi) { + var mode = MODES[gi % MODES.length]; + // Apply the mode's CSS pattern to emphasis words in this group + group.emphasisWords.forEach(function (word) { + applyMode(word.el, mode, tl, word.start); + }); +}); +``` + +Cycle every 2-3 groups for high energy, every 3-4 for medium, every 4-5 for low. diff --git a/.agents/skills/hyperframes/references/dynamic-techniques.md b/.agents/skills/hyperframes/references/dynamic-techniques.md new file mode 100644 index 0000000..c0cab60 --- /dev/null +++ b/.agents/skills/hyperframes/references/dynamic-techniques.md @@ -0,0 +1,90 @@ +# Dynamic Caption Techniques + +You are here because SKILL.md told you to read this file before writing animation code. Pick your technique combination from the table below based on the energy level you detected from the transcript, then implement using standard GSAP patterns. + +## Technique Selection by Energy + +| Energy level | Highlight | Exit | Cycle pattern | +| ------------ | ------------------------------------- | ------------------- | ----------------------------------------- | +| High | Karaoke with accent glow + scale pop | Scatter or drop | Alternate highlight styles every 2 groups | +| Medium-high | Karaoke with color pop | Scatter or collapse | Alternate every 3 groups | +| Medium | Karaoke (subtle, white only) | Fade + slide | Alternate every 3 groups | +| Medium-low | Karaoke (minimal scale change) | Fade | Single style, vary ease per group | +| Low | Karaoke (warm tones, slow transition) | Collapse | Alternate every 4 groups | + +**All energy levels use karaoke highlight as the baseline.** The difference is intensity — high energy gets accent color + glow + 15% scale pop on active words, low energy gets a gentle white shift with 3% scale. + +**Emphasis words always break the pattern.** When a word is flagged as emphasis (emotional keyword, ALL CAPS, brand name), give it a stronger animation than surrounding words (larger scale, accent color, overshoot ease). This creates contrast. + +**Marker highlight modes add a visual layer on top of karaoke.** For emphasis words that need more than color/scale, add a marker-style effect — highlight sweep, circle, burst, or scribble — using the `/marker-highlight` skill. Match mode to energy: burst for hype, circle for key terms, highlight for standard, scribble for subtle. + +## Audio-Reactive Captions (Mandatory for Music) + +**If the source audio is music (vocals over instrumentation, beats, any musical content), you MUST extract audio data and add audio-reactive animations.** This is not optional — music without audio reactivity looks disconnected. Even low-energy ballads get subtle bass pulse and treble glow. + +No special wiring is needed. The group loop already iterates over every caption group to build entrance, karaoke, and exit tweens. At that point, read the audio data for each group's time range and use it to modulate the group's animation intensity with regular GSAP tweens. + +```js +// Load audio data inline (same pattern as TRANSCRIPT) +var AUDIO = JSON.parse(audioDataJson); // { fps, totalFrames, frames: [{ bands: [...] }] } + +GROUPS.forEach(function (group, gi) { + var groupEl = document.getElementById("cg-" + gi); + if (!groupEl) return; + + // Read peak energy for this group's time range + var startFrame = Math.floor(group.start * AUDIO.fps); + var endFrame = Math.min(Math.floor(group.end * AUDIO.fps), AUDIO.totalFrames - 1); + var peakBass = 0; + var peakTreble = 0; + for (var f = startFrame; f <= endFrame; f++) { + var frame = AUDIO.frames[f]; + if (!frame) continue; + peakBass = Math.max(peakBass, frame.bands[0] || 0, frame.bands[1] || 0); + peakTreble = Math.max(peakTreble, frame.bands[6] || 0, frame.bands[7] || 0); + } + + // Modulate entrance — louder groups enter bigger and glowier + tl.to( + groupEl, + { + scale: 1 + peakBass * 0.06, + textShadow: + "0 0 " + Math.round(peakTreble * 12) + "px rgba(255,255,255," + peakTreble * 0.4 + ")", + duration: 0.3, + ease: "power2.out", + }, + group.start, + ); + + // Reset at exit so audio-driven values don't persist + tl.set(groupEl, { scale: 1, textShadow: "none" }, group.end - 0.15); +}); +``` + +This shapes the animation at build time, not playback time — no per-frame callbacks, no `tl.call()` loops, no async fetch timing issues. Loud groups come in with more weight and glow; quiet groups come in soft. The audio data modulates _how much_, the content determines _what_. + +Keep audio reactivity subtle — 3-6% scale variation and soft glow. Heavy pulsing makes text unreadable. + +To generate the audio data file: + +```bash +python3 skills/gsap-effects/scripts/extract-audio-data.py audio.mp3 --fps 30 --bands 8 -o audio-data.json +``` + +## Combining Techniques + +Don't use the same highlight animation on every group — cycle through styles using the group index. Don't combine multiple competing animations on the same word at the same timestamp. Vary techniques across groups to match the content's pace changes. + +**Marker highlight effects** (from the `/marker-highlight` skill) layer well with karaoke — use karaoke for the word-by-word reveal, then add a marker effect on emphasis words only. For example: karaoke highlights each word in white, but brand names get a yellow highlight sweep and stats get a red circle. Cycle marker modes across groups for visual variety (see the mode-to-energy mapping in the marker-highlight skill). + +## Available Tools + +These tools are available in the HyperFrames runtime. Use them when they solve a real problem — not every composition needs all of them. + +| Tool | What it does | Access | When it's useful | +| ------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| **pretext** | Pure-arithmetic text measurement without DOM reflow. 0.0002ms per call. | `window.__hyperframes.pretext.prepare(text, font)` / `.layout(prepared, maxWidth, lineHeight)` | Per-frame text reflow, shrinkwrap containers, computing layout before render | +| **fitTextFontSize** | Finds the largest font size that fits text on one line. Built on pretext. | `window.__hyperframes.fitTextFontSize(text, { maxWidth, fontFamily, fontWeight })` | Overflow prevention for long phrases, portrait mode, large base sizes | +| **audio data** | Pre-extracted per-frame RMS energy and frequency bands. | Extract with `extract-audio-data.py`, load inline or via `fetch("audio-data.json")` | Audio-reactive visuals — modulate intensity based on the music | +| **GSAP** | Animation timeline with tweens and callbacks. | `gsap.to()`, `gsap.set()`, `tl.to()`, `tl.set()` | All caption animation | diff --git a/.agents/skills/hyperframes/references/motion-principles.md b/.agents/skills/hyperframes/references/motion-principles.md new file mode 100644 index 0000000..f02a3bd --- /dev/null +++ b/.agents/skills/hyperframes/references/motion-principles.md @@ -0,0 +1,69 @@ +# Motion Principles + +## Guardrails + +You know these rules but you violate them. Stop. + +- **Don't use the same ease on every tween.** You default to `power2.out` on everything. Vary eases like you vary font weights — no more than 2 independent tweens with the same ease in a scene. +- **Don't use the same speed on everything.** You default to 0.4-0.5s for everything. The slowest scene should be 3× slower than the fastest. Vary duration deliberately. +- **Don't enter everything from the same direction.** You default to `y: 30, opacity: 0` on every element. Vary: from left, from right, from scale, opacity-only, letter-spacing. +- **Don't use the same stagger on every scene.** Each scene needs its own rhythm. +- **Don't use ambient zoom on every scene.** Pick different ambient motion per scene: slow pan, subtle rotation, scale push, color shift, or nothing. Stillness after motion is powerful. +- **Don't start at t=0.** Offset the first animation 0.1-0.3s. Zero-delay feels like a jump cut. + +## What You Don't Do Without Being Told + +### Easing is emotion, not technique + +The transition is the verb. The easing is the adverb. A slide-in with `expo.out` = confident. With `sine.inOut` = dreamy. With `elastic.out` = playful. Same motion, different meaning. Choose the adverb deliberately. + +**Direction rules — these are not optional:** + +- `.out` for elements entering. Starts fast, decelerates. Feels responsive. This is your default. +- `.in` for elements leaving. Starts slow, accelerates away. Throws them off. +- `.inOut` for elements moving between positions. + +You get this backwards constantly. Ease-in for entrances feels sluggish. Ease-out for exits feels reluctant. + +### Speed communicates weight + +- Fast (0.15-0.3s) — energy, urgency, confidence +- Medium (0.3-0.5s) — professional, most content +- Slow (0.5-0.8s) — gravity, luxury, contemplation +- Very slow (0.8-2.0s) — cinematic, emotional, atmospheric + +### Scene structure: build / breathe / resolve + +Every scene has three phases. You dump everything in the build and leave nothing for breathe or resolve. + +- **Build (0-30%)** — elements enter, staggered. Don't dump everything at once. +- **Breathe (30-70%)** — content visible, alive with ONE ambient motion. +- **Resolve (70-100%)** — exit or decisive end. Exits are faster than entrances. + +### Transitions are meaning + +- **Crossfade** = "this continues" +- **Hard cut** = "wake up" / disruption +- **Slow dissolve** = "drift with me" + +You crossfade everything. Use hard cuts for disruption and register shifts. + +### Choreography is hierarchy + +The element that moves first is perceived as most important. Stagger in order of importance, not DOM order. Don't wait for completion — overlap entries. Total stagger sequence under 500ms regardless of item count. + +### Asymmetry + +Entrances need longer than exits. A card takes 0.4s to appear but 0.25s to disappear. + +## Visual Composition + +You build for the web. Video frames are not pages. + +- **Two focal points minimum per scene.** The eye needs somewhere to travel. Never a single text block floating in empty space. +- **Fill the frame.** Hero text: 60-80% of width. You will try to use web-sized elements. Don't. +- **Three layers minimum per scene.** Background treatment (glow, oversized faded type, color panel). Foreground content. Accent elements (dividers, labels, data bars). +- **Background is not empty.** Radial glows, oversized faded type bleeding off-frame, subtle border panels, hairline rules. Pure solid #000 reads as "nothing loaded." +- **Anchor to edges.** Pin content to left/top or right/bottom. Centered-and-floating is a web pattern. +- **Split frames.** Data panel on the left, content on the right. Top bar with metadata, full-width below. Zone-based layouts, not centered stacks. +- **Use structural elements.** Rules, dividers, border panels. They create paths for the eye and animate well (scaleX from 0). diff --git a/.agents/skills/hyperframes/references/transcript-guide.md b/.agents/skills/hyperframes/references/transcript-guide.md new file mode 100644 index 0000000..5bbf764 --- /dev/null +++ b/.agents/skills/hyperframes/references/transcript-guide.md @@ -0,0 +1,151 @@ +# Transcript Guide + +## How Transcripts Are Generated + +`hyperframes transcribe` handles both transcription and format conversion: + +```bash +# Transcribe audio/video (uses whisper.cpp locally, no API key needed) +npx hyperframes transcribe audio.mp3 + +# Use a larger model for better accuracy +npx hyperframes transcribe audio.mp3 --model medium.en + +# Filter to English only (skips non-English speech) +npx hyperframes transcribe audio.mp3 --language en + +# Import an existing transcript from another tool +npx hyperframes transcribe captions.srt +npx hyperframes transcribe captions.vtt +npx hyperframes transcribe openai-response.json +``` + +## Supported Input Formats + +The CLI auto-detects and normalizes these formats: + +| Format | Extension | Source | Word-level? | +| --------------------- | --------- | --------------------------------------------------------------------------- | ----------------- | +| whisper.cpp JSON | `.json` | `hyperframes init --video`, `hyperframes transcribe` | Yes | +| OpenAI Whisper API | `.json` | `openai.audio.transcriptions.create({ timestamp_granularities: ["word"] })` | Yes | +| SRT subtitles | `.srt` | Video editors, subtitle tools, YouTube | No (phrase-level) | +| VTT subtitles | `.vtt` | Web players, YouTube, transcription services | No (phrase-level) | +| Normalized word array | `.json` | Pre-processed by any tool | Yes | + +**Word-level timestamps produce better captions.** SRT/VTT give phrase-level timing, which works but can't do per-word animation effects. + +## Whisper Model Guide + +The default model (`small.en`) balances accuracy and speed. For better results, use a larger model: + +| Model | Size | Speed | Accuracy | When to use | +| ---------- | ------ | -------- | --------- | ------------------------------------- | +| `tiny` | 75 MB | Fastest | Low | Quick previews, testing pipeline | +| `base` | 142 MB | Fast | Fair | Short clips, clear audio | +| `small` | 466 MB | Moderate | Good | **Default** — good for most content | +| `medium` | 1.5 GB | Slow | Very good | Important content, noisy audio, music | +| `large-v3` | 3.1 GB | Slowest | Best | Production quality | + +**Only add `.en` suffix when the user explicitly says the audio is English.** `.en` models are slightly more accurate for English but will TRANSLATE non-English audio instead of transcribing it. + +**Critical: `.en` models translate non-English audio into English** — they don't transcribe it. If the audio might not be English, always use a model without the `.en` suffix and pass `--language` to specify the source language. If you're unsure of the language, use `small` (not `small.en`) without `--language` — whisper will auto-detect. + +```bash +# Spanish audio +npx hyperframes transcribe audio.mp3 --model small --language es + +# Unknown language — let whisper auto-detect +npx hyperframes transcribe audio.mp3 --model small +``` + +**Music and vocals over instrumentation**: `small.en` will misidentify lyrics — use `medium.en` as the minimum, or import lyrics manually. Even `medium.en` struggles with heavily produced tracks; for music videos, providing known lyrics as an SRT/VTT and importing with `hyperframes transcribe lyrics.srt` will always beat automated transcription. + +## Transcript Quality Check (Mandatory) + +After every transcription, **read the transcript and check for quality issues before proceeding.** Bad transcripts produce nonsensical captions. Never skip this step. + +### What to look for + +| Signal | Example | Cause | +| ---------------------------- | -------------------------------------- | ---------------------------------------------------------------------------- | +| Music note tokens (`♪`, `�`) | `{ "text": "♪" }` or `{ "text": "�" }` | Whisper detected music, not speech | +| Garbled / nonsense words | "Do a chin", "Get so gay", "huh" | Model misheard lyrics or background noise | +| Long gaps with no words | 20+ seconds of only `♪` tokens | Instrumental section — expected, but high ratio means speech is being missed | +| Repeated filler | Many "huh", "uh", "oh" entries | Model is hallucinating on music | +| Very short word spans | Words with `end - start < 0.05` | Unreliable timestamp alignment | + +### Automatic retry rules + +**If more than 20% of entries are `♪`/`�` tokens, or the transcript contains obvious nonsense words, the transcription failed.** Do not proceed with the bad transcript. Instead: + +1. **Retry with `medium.en`** if the original used `small.en` or smaller: + ```bash + npx hyperframes transcribe audio.mp3 --model medium.en + ``` +2. **If `medium.en` also fails** (still >20% music tokens or garbled), tell the user the audio is too noisy for local transcription and suggest: + - Providing lyrics manually as an SRT/VTT file + - Using an external API (OpenAI or Groq Whisper — see below) +3. **Always clean the transcript** before building captions — filter out `♪`/`�` tokens and entries where `text` is a single non-word character. Only real words should reach the caption composition. + +### Cleaning a transcript + +After transcription (even with a good model), strip non-word entries: + +```js +var raw = JSON.parse(transcriptJson); +var words = raw.filter(function (w) { + if (!w.text || w.text.trim().length === 0) return false; + if (/^[♪�\u266a\u266b\u266c\u266d\u266e\u266f]+$/.test(w.text)) return false; + if (/^(huh|uh|um|ah|oh)$/i.test(w.text) && w.end - w.start < 0.1) return false; + return true; +}); +``` + +### When to use which model (decision tree) + +1. **Is this speech over silence/light background?** → `small.en` is fine +2. **Is this speech over music, or music with vocals?** → Start with `medium.en` +3. **Is this a produced music track (vocals + full instrumentation)?** → Start with `medium.en`, expect to need manual lyrics or an external API +4. **Is this multilingual?** → Use `medium` or `large-v3` (no `.en` suffix) + +## Using External Transcription APIs + +For the best accuracy, use an external API and import the result: + +**OpenAI Whisper API** (recommended for quality): + +```bash +# Generate with word timestamps, then import +curl https://api.openai.com/v1/audio/transcriptions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -F file=@audio.mp3 -F model=whisper-1 \ + -F response_format=verbose_json \ + -F "timestamp_granularities[]=word" \ + -o transcript-openai.json + +npx hyperframes transcribe transcript-openai.json +``` + +**Groq Whisper API** (fast, free tier available): + +```bash +curl https://api.groq.com/openai/v1/audio/transcriptions \ + -H "Authorization: Bearer $GROQ_API_KEY" \ + -F file=@audio.mp3 -F model=whisper-large-v3 \ + -F response_format=verbose_json \ + -F "timestamp_granularities[]=word" \ + -o transcript-groq.json + +npx hyperframes transcribe transcript-groq.json +``` + +## If No Transcript Exists + +1. Check the project root for `transcript.json`, `.srt`, or `.vtt` files +2. If none found, run transcription — pick the starting model based on the content type: + - Speech/voiceover → `small.en` + - Music with vocals → `medium.en` + ```bash + npx hyperframes transcribe --model medium.en + ``` +3. **Read the transcript and run the quality check** (see above). If it fails, retry with a larger model or suggest manual lyrics. diff --git a/.agents/skills/hyperframes/references/transitions.md b/.agents/skills/hyperframes/references/transitions.md new file mode 100644 index 0000000..3b5404b --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions.md @@ -0,0 +1,112 @@ +# Scene Transitions + +A transition tells the viewer how two scenes relate. A crossfade says "this continues." A push slide says "next point." A blur crossfade says "drift with me." Choose transitions that match what the content is doing emotionally, not just technically. + +## Animation Rules for Multi-Scene Compositions + +These are non-negotiable for every multi-scene composition: + +1. **Every composition uses transitions.** No exceptions. Scenes without transitions feel like jump cuts. +2. **Every scene uses entrance animations.** Elements animate IN via `gsap.from()` — opacity, position, scale, etc. No scene should pop fully-formed onto screen. +3. **Exit animations are BANNED** except on the final scene. Do NOT use `gsap.to()` to animate elements out before a transition fires. The transition IS the exit. Outgoing scene content must be fully visible when the transition starts — the transition handles the visual handoff. +4. **Final scene exception:** The last scene MAY fade elements out (e.g., fade to black at the end of the composition). This is the only scene where exit animations are allowed. + +## Energy → Primary Transition + +| Energy | CSS Primary | Shader Primary | Accent | Duration | Easing | +| ---------------------------------------- | ---------------------------- | ------------------------------------ | ------------------------------ | --------- | ---------------------- | +| **Calm** (wellness, brand story, luxury) | Blur crossfade, focus pull | Cross-warp morph, thermal distortion | Light leak, circle iris | 0.5-0.8s | `sine.inOut`, `power1` | +| **Medium** (corporate, SaaS, explainer) | Push slide, staggered blocks | Whip pan, cinematic zoom | Squeeze, vertical push | 0.3-0.5s | `power2`, `power3` | +| **High** (promos, sports, music, launch) | Zoom through, overexposure | Ridged burn, glitch, chromatic split | Staggered blocks, gravity drop | 0.15-0.3s | `power4`, `expo` | + +Pick ONE primary (60-70% of scene changes) + 1-2 accents. Never use a different transition for every scene. + +## Mood → Transition Type + +Think about what the transition _communicates_, not just what it looks like. + +| Mood | Transitions | Why it works | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| **Warm / inviting** | Light leak, blur crossfade, focus pull, film burn · **Shader:** thermal distortion, light leak, cross-warp morph | Soft edges, warm color washes. Nothing sharp or mechanical. | +| **Cold / clinical** | Squeeze, zoom out, blinds, shutter, grid dissolve · **Shader:** gravitational lens | Content transforms mechanically — compressed, shrunk, sliced, gridded. | +| **Editorial / magazine** | Push slide, vertical push, diagonal split, shutter · **Shader:** whip pan | Like turning a page or slicing a layout. Clean directional movement. | +| **Tech / futuristic** | Grid dissolve, staggered blocks, blinds, chromatic aberration · **Shader:** glitch, chromatic split | Grid dissolve is the core "data" transition. Shader glitch adds posterization + scan lines. | +| **Tense / edgy** | Glitch, VHS, chromatic aberration, ripple · **Shader:** ridged burn, glitch, domain warp | Instability, distortion, digital breakdown. Ridged burn adds sharp lightning-crack edges. | +| **Playful / fun** | Elastic push, 3D flip, circle iris, morph circle, clock wipe · **Shader:** ripple waves, swirl vortex | Overshoot, bounce, rotation, expansion. Swirl vortex adds organic spiral distortion. | +| **Dramatic / cinematic** | Zoom through, zoom out, gravity drop, overexposure, color dip to black · **Shader:** cinematic zoom, gravitational lens, domain warp | Scale, weight, light extremes. Shader transitions add per-pixel depth. | +| **Premium / luxury** | Focus pull, blur crossfade, color dip to black · **Shader:** cross-warp morph, thermal distortion | Restraint. Cross-warp morph flows both scenes into each other organically. | +| **Retro / analog** | Film burn, light leak, VHS, clock wipe · **Shader:** light leak | Organic imperfection. Warm color bleeds, scan line displacement. | + +## Narrative Position + +| Position | Use | Why | +| -------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- | +| **Opening** | Your most distinctive transition. Match the mood. 0.4-0.6s | Sets the visual language for the entire piece. | +| **Between related points** | Your primary transition. Consistent. 0.3s | Don't distract — the content is continuing. | +| **Topic change** | Something different from your primary. Staggered blocks, shutter, squeeze. | Signals "new section" — the viewer's brain resets. | +| **Climax / hero reveal** | Your boldest accent. Fastest or most dramatic. | This is the payoff — spend your best transition here. | +| **Wind-down** | Return to gentle. Blur crossfade, crossfade. 0.5-0.7s | Let the viewer exhale after the climax. | +| **Outro** | Slowest, simplest. Crossfade, color dip to black. 0.6-1.0s | Closure. Don't introduce new energy at the end. | + +## Blur Intensity by Energy + +| Energy | Blur | Duration | Hold at peak | +| ---------- | ------- | -------- | ------------ | +| **Calm** | 20-30px | 0.8-1.2s | 0.3-0.5s | +| **Medium** | 8-15px | 0.4-0.6s | 0.1-0.2s | +| **High** | 3-6px | 0.2-0.3s | 0s | + +## Presets + +| Preset | Duration | Easing | +| ---------- | -------- | ----------------- | +| `snappy` | 0.2s | `power4.inOut` | +| `smooth` | 0.4s | `power2.inOut` | +| `gentle` | 0.6s | `sine.inOut` | +| `dramatic` | 0.5s | `power3.in` → out | +| `instant` | 0.15s | `expo.inOut` | +| `luxe` | 0.7s | `power1.inOut` | + +## Implementation + +Read [transitions/catalog.md](transitions/catalog.md) for GSAP code and hard rules for every transition type. + +| Category | CSS | Shader (WebGL) | +| ----------- | -------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Push/slide | Push slide, vertical push, elastic push, squeeze | Whip pan | +| Scale/zoom | Zoom through, zoom out, gravity drop, 3D flip | Cinematic zoom, gravitational lens | +| Reveal/mask | Circle iris, diamond iris, diagonal split, clock wipe, shutter | SDF iris | +| Dissolve | Crossfade, blur crossfade, focus pull, color dip | Cross-warp morph, domain warp | +| Cover | Staggered blocks, horizontal blinds, vertical blinds | — | +| Light | Light leak, overexposure burn, film burn | Light leak (shader), thermal distortion | +| Distortion | Glitch, chromatic aberration, ripple, VHS tape | Glitch (shader), chromatic split, ridged burn, ripple waves, swirl vortex | +| Pattern | Grid dissolve, morph circle | — | + +## Transitions That Don't Work in CSS + +Avoid: star iris, tilt-shift, lens flare, hinge/door. See catalog.md for why. + +## CSS vs Shader + +CSS transitions animate scene containers with opacity, transforms, clip-path, and filters. Shader transitions composite both scene textures per-pixel on a WebGL canvas — they can warp, dissolve, and morph in ways CSS cannot. + +**Both are first-class options.** Shaders are provided by the `@hyperframes/shader-transitions` package — import from the package instead of writing raw GLSL. CSS transitions are simpler to set up. Choose based on the effect you want, not based on which is easier. + +When a composition uses shader transitions, ALL transitions in that composition should be shader-based (the WebGL canvas replaces DOM-based scene switching). Don't mix CSS and shader transitions in the same composition. + +## Shader-Compatible CSS Rules + +Shader transitions capture DOM scenes to WebGL textures via html2canvas. The canvas 2D rendering pipeline doesn't match CSS exactly. Follow these rules to avoid visible artifacts at transition boundaries: + +1. **No `transparent` keyword in gradients.** Canvas interpolates `transparent` as `rgba(0,0,0,0)` (black at zero alpha), creating dark fringes. Always use the target color at zero alpha: `rgba(200,117,51,0)` not `transparent`. +2. **No gradient backgrounds on elements thinner than 4px.** Canvas can't match CSS gradient rendering on 1-2px elements. Use solid `background-color` on thin accent lines. +3. **No CSS variables (`var()`) on elements visible during capture.** html2canvas doesn't reliably resolve custom properties. Use literal color values in inline styles. +4. **Mark uncapturable decorative elements with `data-no-capture`.** The capture function skips these. They're present on the live DOM but absent from the shader texture. Use for elements that can't follow the rules above. +5. **No gradient opacity below 0.15.** Gradient elements below 10% opacity render differently in canvas vs CSS. Increase to 0.15+ or use a solid color at equivalent brightness. +6. **Every `.scene` div must have explicit `background-color`, AND pass the same color as `bgColor` in the `init()` config.** The package captures scene elements via html2canvas. Both the CSS `background-color` on `.scene` and the `bgColor` config must match. Without either, the texture renders as black. + +These rules only apply to shader transition compositions. CSS-only compositions have no restrictions. + +## Visual Pattern Warning + +Avoid transitions that create visible repeating geometric patterns — grids of tiles, hexagonal cells, uniform dot arrays, evenly-spaced blob circles. These look cheap and artificial regardless of the math behind them. Organic noise (FBM, domain warping) is good because it's irregular. Geometric repetition is bad because the eye instantly sees the grid. diff --git a/.agents/skills/hyperframes/references/transitions/catalog.md b/.agents/skills/hyperframes/references/transitions/catalog.md new file mode 100644 index 0000000..4918b77 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/catalog.md @@ -0,0 +1,117 @@ +# Transition Catalog + +Hard rules, scene template, and routing to implementation code. Read the reference file for the transition type you need — don't load all of them. + +## Hard Rules (CSS) + +These cause real bugs if violated. + +**Scene visibility:** Scene 1 visible by default (no `opacity: 0`). Scenes 2+ have `opacity: 0` on the CONTAINER div. GSAP reveals them. No visibility shim (`timedEls`). + +**Fonts:** Just write the `font-family` you want — the compiler embeds supported fonts automatically via `@font-face` with inline data URIs. No need for `` tags or `@import`. Works in all contexts including sandboxed iframes. + +**Element structure:** No `class="clip"` on scene divs in standalone compositions. Only the root div gets `data-composition-id`/`data-start`/`data-duration`. + +**Overlay elements:** Staggered blocks = full-screen 1920x1080, NOT thin strips. Glitch RGB overlays = normal blending at 35% opacity, NOT `mix-blend-mode: multiply` (invisible on dark backgrounds). Light leak overlays = larger than the frame (2400px+), never a visible shape. Overexposure = use `filter: brightness()` on the scene, not just a white overlay. + +**VHS tape:** Clone actual scene content with `cloneNode(true)`, NOT colored bars. Each strip: wider than frame (2020px at left:-50px). Red+blue chromatic copies at z-index above main strip. Seeded PRNG for deterministic random offsets. + +**Z-index:** Gravity drop, zoom out, diagonal split need outgoing scene ON TOP (`zIndex: 10`) so it exits while revealing the new scene behind (`zIndex: 1`). + +**Page burn:** Content burns with the page — no falling debris. Hide scene1 via `tl.set` at burn end, NEVER `onComplete` (not reversible). `onUpdate` must restore `clipPath: "none"` when `wp <= 0` for rewind support. Incoming scene fades from black at 90% through burn. + +**Clock wipe:** 9-point polygon with intermediate edge positions. Step through 4 quadrants with separate tweens. + +**Grid dissolve:** Cycle 5 palette colors per cell, not monochrome. + +**Blinds count by energy:** Calm: 4h/6v. Medium: 6-8h/8v. High: 12-16h/16v. + +**Don't use:** Star iris (polygon interpolation broken), tilt-shift (no selective CSS blur), lens flare (visible shape, not optical), hinge/door (distorts too fast). + +## Shader Transitions + +Shader setup, WebGL init, capture, and fragment shaders are handled by `@hyperframes/shader-transitions` (`packages/shader-transitions/`). Read the package source for API details. Compositions using shaders must follow the CSS rules in [transitions.md](../transitions.md) § "Shader-Compatible CSS Rules". + +## Scene Template + +```html + + + + + + + + +
+
+
+
+ + + +``` + +Every transition follows: position new scene → animate outgoing → swap → animate incoming → clean up overlays. + +## CSS Transitions + +All code examples use `old` for the outgoing scene-inner selector and `new` for the incoming, with `T` as the transition start time. Read the reference file for the type you need. + +| Type | Transitions | Reference | +| -------------- | ---------------------------------------------------- | ------------------------------------------ | +| Push | Push slide, vertical push, elastic push, squeeze | [css-push.md](./css-push.md) | +| Radial / Shape | Circle iris, diamond iris, diagonal split | [css-radial.md](./css-radial.md) | +| 3D | 3D card flip | [css-3d.md](./css-3d.md) | +| Scale / Zoom | Zoom through, zoom out | [css-scale.md](./css-scale.md) | +| Dissolve | Crossfade, blur crossfade, focus pull, color dip | [css-dissolve.md](./css-dissolve.md) | +| Cover | Staggered blocks, horizontal blinds, vertical blinds | [css-cover.md](./css-cover.md) | +| Light | Light leak, overexposure burn, film burn | [css-light.md](./css-light.md) | +| Distortion | Glitch, chromatic aberration, ripple, VHS tape | [css-distortion.md](./css-distortion.md) | +| Mechanical | Shutter, clock wipe | [css-mechanical.md](./css-mechanical.md) | +| Grid | Grid dissolve | [css-grid.md](./css-grid.md) | +| Other | Gravity drop, morph circle | [css-other.md](./css-other.md) | +| Blur | Blur through, directional blur | [css-blur.md](./css-blur.md) | +| Destruction | Page burn | [css-destruction.md](./css-destruction.md) | + +## Shader Transitions + +WebGL shader transitions are provided by `@hyperframes/shader-transitions` (`packages/shader-transitions/`). The package handles setup, capture, WebGL init, render loop, and GSAP integration. Read the package source for available shaders and API — do not copy raw GLSL manually. diff --git a/.agents/skills/hyperframes/references/transitions/css-3d.md b/.agents/skills/hyperframes/references/transitions/css-3d.md new file mode 100644 index 0000000..b86b520 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-3d.md @@ -0,0 +1,12 @@ +## 3D + +### 3D Card Flip + +180° Y-axis rotation. Requires CSS: `backface-visibility: hidden; transform-style: preserve-3d;` on both scene-inners. Parent needs `perspective: 1200px`. + +```js +tl.set(new, { rotationY: -180, opacity: 1 }, T); +tl.to(old, { rotationY: 180, duration: 0.6, ease: "power2.inOut" }, T); +tl.to(new, { rotationY: 0, duration: 0.6, ease: "power2.inOut" }, T); +tl.set(old, { opacity: 0 }, T + 0.6); +``` diff --git a/.agents/skills/hyperframes/references/transitions/css-blur.md b/.agents/skills/hyperframes/references/transitions/css-blur.md new file mode 100644 index 0000000..2698ba9 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-blur.md @@ -0,0 +1,51 @@ +## Blur + +All blur transitions scale with energy. See SKILL.md "Blur Intensity by Energy" for the full table. + +### Blur Through + +Content becomes fully abstract before resolving. The heaviest blur transition. + +**Calm (default for this type — it's inherently heavy):** + +```js +tl.to(old, { filter: "blur(30px)", scale: 1.08, duration: 0.5, ease: "power1.in" }, T); +tl.to(old, { opacity: 0, duration: 0.3, ease: "power1.in" }, T + 0.3); +// Hold: both scenes in abstract blur state +tl.fromTo(new, + { filter: "blur(30px)", scale: 0.92, opacity: 0 }, + { filter: "blur(30px)", scale: 0.92, opacity: 1, duration: 0.2, ease: "none" }, T + 0.5); +// Slow resolve +tl.to(new, { filter: "blur(0px)", scale: 1, duration: 0.7, ease: "power1.out" }, T + 0.7); +``` + +**Medium:** + +```js +tl.to(old, { filter: "blur(15px)", scale: 1.05, opacity: 0, duration: 0.4, ease: "power2.in" }, T); +tl.fromTo(new, + { filter: "blur(15px)", scale: 0.95, opacity: 0 }, + { filter: "blur(0px)", scale: 1, opacity: 1, duration: 0.4, ease: "power2.out" }, T + 0.2); +``` + +### Directional Blur + +Blur + skew simulating motion in one direction. Scale blur and skew with energy. + +**Medium (default):** + +```js +tl.to(old, { filter: "blur(12px)", skewX: -8, x: -200, opacity: 0, duration: 0.4, ease: "power3.in" }, T); +tl.fromTo(new, + { filter: "blur(12px)", skewX: 8, x: 200, opacity: 0 }, + { filter: "blur(0px)", skewX: 0, x: 0, opacity: 1, duration: 0.4, ease: "power3.out" }, T + 0.15); +``` + +**Calm (heavier blur, gentler motion):** + +```js +tl.to(old, { filter: "blur(20px)", skewX: -4, x: -100, opacity: 0, duration: 0.6, ease: "power1.in" }, T); +tl.fromTo(new, + { filter: "blur(20px)", skewX: 4, x: 100, opacity: 0 }, + { filter: "blur(0px)", skewX: 0, x: 0, opacity: 1, duration: 0.6, ease: "power1.out" }, T + 0.3); +``` diff --git a/.agents/skills/hyperframes/references/transitions/css-cover.md b/.agents/skills/hyperframes/references/transitions/css-cover.md new file mode 100644 index 0000000..6ec60b8 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-cover.md @@ -0,0 +1,43 @@ +## Cover + +### Staggered Color Blocks + +Full-screen (1920x1080) colored divs slide across staggered. Scene swaps while covered. + +**2-block** (standard): + +```js +tl.set("#wipe-a", { x: -1920 }, T - 0.01); +tl.set("#wipe-b", { x: -1920 }, T - 0.01); +tl.to("#wipe-a", { x: 0, duration: 0.25, ease: "power3.inOut" }, T); +tl.to("#wipe-b", { x: 0, duration: 0.25, ease: "power3.inOut" }, T + 0.06); +tl.set(old, { opacity: 0 }, T + 0.2); +tl.set(new, { opacity: 1 }, T + 0.2); +tl.to("#wipe-a", { x: 1920, duration: 0.25, ease: "power3.inOut" }, T + 0.28); +tl.to("#wipe-b", { x: 1920, duration: 0.25, ease: "power3.inOut" }, T + 0.34); +``` + +**5-block** (dense variant): same pattern with 5 blocks at 0.04s stagger. Use composition palette colors. + +### Horizontal Blinds + +Full-width strips slide across staggered. Each strip: `width: 1920px; height: Xpx`. + +**6 strips** (180px each): `0.03s` stagger +**12 strips** (90px each): `0.018s` stagger + +```js +for (var i = 0; i < N; i++) { + tl.set("#blind-h-" + i, { x: -1920 }, T - 0.01); + tl.fromTo("#blind-h-" + i, { x: -1920 }, { x: 0, duration: 0.2, ease: "power3.inOut" }, T + i * stagger); +} +tl.set(old, { opacity: 0 }, T + coverTime); +tl.set(new, { opacity: 1 }, T + coverTime); +for (var i = 0; i < N; i++) { + tl.to("#blind-h-" + i, { x: 1920, duration: 0.2, ease: "power3.inOut" }, T + exitStart + i * stagger); +} +``` + +### Vertical Blinds + +Same as horizontal but strips are tall and narrow, moving on Y axis. diff --git a/.agents/skills/hyperframes/references/transitions/css-destruction.md b/.agents/skills/hyperframes/references/transitions/css-destruction.md new file mode 100644 index 0000000..9229cff --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-destruction.md @@ -0,0 +1,95 @@ +## Destruction + +### Page Burn + +The outgoing scene literally burns away from a corner. A fire front expands with noise-based irregular edges, a canvas draws the scorched char line at the burn boundary, and individual text characters/elements chip off and fall with gravity as the fire reaches them. The incoming scene reveals behind the burn. + +This transition has three systems working together: + +1. **Fire geometry** — a radial front expanding from a corner (e.g., bottom-right) with noise-based irregularity for organic edges +2. **Scene clipping** — the outgoing scene uses an SVG clip-path (with `fill-rule: evenodd`) that cuts a hole matching the fire front. As the fire expands, more of the scene is clipped away. All content (text, images, lines) burns with the page — no separate debris. +3. **Scorched edge** — a `` overlay draws a radial gradient fringe at the fire boundary to simulate charring + +**When to use:** Dramatic reveals, edgy/destructive mood, gaming, cyberpunk. This is the most dramatic transition in the catalog — reserve it for hero moments. + +**Requirements:** + +- A `` element for the burn edge overlay +- A noise function for organic fire edge geometry +- SVG clip-path with evenodd fill-rule for the inverted clip + +**Fire geometry (deterministic noise):** + +```js +function noise(x) { + var ix = Math.floor(x), + fx = x - ix; + var a = Math.sin(ix * 127.1 + 311.7) * 43758.5453; + var b = Math.sin((ix + 1) * 127.1 + 311.7) * 43758.5453; + var t = fx * fx * (3 - 2 * fx); + return a - Math.floor(a) + (b - Math.floor(b) - (a - Math.floor(a))) * t; +} + +function fireRadiusAtAngle(angle, progress) { + var base = progress * maxRadius; + return ( + base + + noise(angle * 3 + progress * 4) * 50 + + noise(angle * 8 + progress * 9) * 20 + + noise(angle * 15 + progress * 15) * 8 + ); +} +``` + +**Incoming scene timing:** The incoming scene should NOT be visible during the burn. As the fire consumes the outgoing scene, **black shows through the holes** — this is the dramatic part. The viewer watches content being destroyed against blackness. + +At ~90% through the burn, the incoming scene fades in SLOWLY from black — the background first, then content staggered. Use long, gentle fades (`power1.out`, 0.8-1.2s durations) so it feels like the new scene materializes from darkness, not a hard swap. + +```js +// Scene 2 stays at opacity: 0 during the burn — black behind the fire +tl.set("#s2-title", { opacity: 0 }, T); +tl.set("#s2-subtitle", { opacity: 0 }, T); + +// At 90% through, scene bg fades in slowly from black +var contentReveal = T + BURN_DURATION * 0.9; +tl.to("#scene2", { opacity: 1, duration: 1.2, ease: "power1.out" }, contentReveal); + +// Content fades in staggered on top, even slower +tl.to("#s2-title", { opacity: 1, duration: 1.0, ease: "power1.out" }, contentReveal + 0.5); +tl.to("#s2-subtitle", { opacity: 1, duration: 0.8, ease: "power1.out" }, contentReveal + 0.7); +``` + +**Content burns with the page — no falling debris.** The clip-path on scene1 IS the effect — as the fire shape expands, everything behind the fire edge (text, images, lines) disappears naturally. Don't clone elements, don't create falling debris. The content is part of the page being consumed. The scorched canvas edge provides the visual char line at the burn boundary. + +**Hide scene1 via `tl.set` at burn end — NEVER in `onComplete`.** Using `onComplete` to hide scene1 is not reversible when scrubbing. Instead, use a `tl.set` at the exact burn end time: + +```js +tl.to( + burnState, + { + progress: 1, + duration: BURN_DURATION, + ease: "none", + onUpdate: function () { + var wp = burnState.progress; + var scene1 = document.getElementById("scene1"); + if (wp <= 0) { + scene1.style.clipPath = "none"; // fully visible when rewound + } else if (wp < 1) { + scene1.style.clipPath = buildClipPath(wp); + } + drawEdge(wp); + }, + // NO onComplete — use tl.set instead + }, + T, +); + +// Hide scene1 at exact burn end — reversible via timeline +tl.set("#scene1", { opacity: 0 }, T + BURN_DURATION); +tl.set("#scene1", { clipPath: "none" }, T + BURN_DURATION); +``` + +The `onUpdate` handles clip-path and canvas edge per-frame. The `tl.set` handles the final hide — and GSAP automatically reverses it when scrubbing backward, restoring scene1 to `opacity: 1`. + +The `onUpdate` callback is the key — it runs every frame to advance the clip-path and canvas edge in sync with the timeline. diff --git a/.agents/skills/hyperframes/references/transitions/css-dissolve.md b/.agents/skills/hyperframes/references/transitions/css-dissolve.md new file mode 100644 index 0000000..3966fa9 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-dissolve.md @@ -0,0 +1,66 @@ +## Dissolve + +### Crossfade + +Simple opacity swap. The baseline. + +```js +tl.to(old, { opacity: 0, duration: 0.5, ease: "power2.inOut" }, T); +tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power2.inOut" }, T); +``` + +### Blur Crossfade + +Dissolve with blur + scale shift. **Scale blur amount by energy** — see SKILL.md "Blur Intensity by Energy" section. The examples below show the medium (default) version. For calm compositions, increase to 20-30px with a 0.3-0.5s hold at peak blur. For high-energy, decrease to 3-6px with no hold. + +**Medium (default):** + +```js +tl.to(old, { filter: "blur(10px)", scale: 1.03, opacity: 0, duration: 0.5, ease: "power2.inOut" }, T); +tl.fromTo(new, + { filter: "blur(10px)", scale: 0.97, opacity: 0 }, + { filter: "blur(0px)", scale: 1, opacity: 1, duration: 0.5, ease: "power2.inOut" }, T + 0.1); +``` + +**Calm (wellness, luxury) — heavy blur, holds at abstract color:** + +```js +tl.to(old, { filter: "blur(25px)", scale: 1.05, duration: 0.6, ease: "power1.in" }, T); +tl.to(old, { opacity: 0, duration: 0.4, ease: "power1.in" }, T + 0.4); +tl.fromTo(new, + { filter: "blur(25px)", scale: 0.95, opacity: 0 }, + { filter: "blur(25px)", scale: 0.95, opacity: 1, duration: 0.3, ease: "power1.inOut" }, T + 0.5); +tl.to(new, { filter: "blur(0px)", scale: 1, duration: 0.6, ease: "power1.out" }, T + 0.8); +``` + +### Focus Pull + +Outgoing slowly blurs while incoming fades in sharp. Depth-of-field feel. **Scale blur amount and hold duration by energy.** + +**Medium:** + +```js +tl.to(old, { filter: "blur(15px)", duration: 0.5, ease: "power1.in" }, T); +tl.to(old, { opacity: 0, duration: 0.3, ease: "power2.in" }, T + 0.25); +tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "power2.out" }, T + 0.25); +``` + +**Calm — slow rack focus with long hold at peak defocus:** + +```js +tl.to(old, { filter: "blur(30px)", duration: 0.8, ease: "power1.in" }, T); +tl.to(old, { opacity: 0, duration: 0.5, ease: "power1.in" }, T + 0.6); +tl.fromTo(new, { opacity: 0, filter: "blur(20px)" }, + { opacity: 1, filter: "blur(20px)", duration: 0.3, ease: "power1.inOut" }, T + 0.7); +tl.to(new, { filter: "blur(0px)", duration: 0.6, ease: "power1.out" }, T + 1.0); +``` + +### Color Dip + +Fade to solid color, hold, fade up new scene. + +```js +tl.to(old, { opacity: 0, duration: 0.2, ease: "power2.in" }, T); +// Background color shows through +tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "power2.out" }, T + 0.25); +``` diff --git a/.agents/skills/hyperframes/references/transitions/css-distortion.md b/.agents/skills/hyperframes/references/transitions/css-distortion.md new file mode 100644 index 0000000..44627f7 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-distortion.md @@ -0,0 +1,45 @@ +## Distortion + +### Glitch + +RGB-tinted overlays (NOT multiply blend — use normal blending at 35% opacity) jitter with large offsets. Scene itself also jitters. + +```js +tl.set("#glitch-r", { opacity: 1, x: 40, y: -8 }, T); +tl.set("#glitch-g", { opacity: 1, x: -30, y: 12 }, T); +tl.set("#glitch-b", { opacity: 1, x: 15, y: -20 }, T); +tl.set(old, { x: -15 }, T); +// 6 jitter frames at 0.03s intervals with big offsets (±30-60px) +// ... swap and clear at T + 0.2 +``` + +### Chromatic Aberration + +RGB overlays start aligned then spread apart (±80px), scene fades, converge on new scene. + +```js +tl.set("#glitch-r", { opacity: 0.6, x: 0 }, T); +tl.set("#glitch-g", { opacity: 0.6, x: 0 }, T); +tl.set("#glitch-b", { opacity: 0.6, x: 0 }, T); +tl.to("#glitch-r", { x: -80, opacity: 0.8, duration: 0.3, ease: "power2.in" }, T); +tl.to("#glitch-b", { x: 80, opacity: 0.8, duration: 0.3, ease: "power2.in" }, T); +tl.to("#glitch-g", { y: 30, duration: 0.3, ease: "power2.in" }, T); +// Swap at T + 0.3, converge back at T + 0.3 +``` + +### Ripple + +Rapid oscillation (±30px) + scale distortion (0.97-1.03) + increasing blur. Swap at peak distortion. + +```js +tl.to(old, { x: 30, scale: 1.02, duration: 0.04, ease: "none" }, T); +tl.to(old, { x: -25, scale: 0.98, filter: "blur(4px)", duration: 0.04, ease: "none" }, T + 0.04); +// ... more oscillations with increasing blur +// Swap at peak, incoming stabilizes with decreasing wobble +``` + +### VHS Tape + +Clone scene into 20 horizontal strips (each 54px, clip-path'd). Each strip shifts x independently with seeded pseudo-random offsets at per-bar random intervals. Add red+blue chromatic offset copies on each strip (z-index above main, 35% opacity). Make strips wider than frame (2020px at left:-50px) so edges never show. + +See SKILL.md for clone-based implementation pattern. diff --git a/.agents/skills/hyperframes/references/transitions/css-grid.md b/.agents/skills/hyperframes/references/transitions/css-grid.md new file mode 100644 index 0000000..ee16700 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-grid.md @@ -0,0 +1,10 @@ +## Grid + +### Grid Dissolve + +Grid of colored cells covers the frame in a ripple from center. Scene swaps at 50% coverage. Cells fade out in ripple. + +**12-cell** (4x3, each 480x270): standard +**120-cell** (12x10, each 160x108): dense variant — lower opacity (0.75), tighter ripple + +Cells are created dynamically in JS, sorted by distance from center for ripple stagger. diff --git a/.agents/skills/hyperframes/references/transitions/css-light.md b/.agents/skills/hyperframes/references/transitions/css-light.md new file mode 100644 index 0000000..08d4d52 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-light.md @@ -0,0 +1,49 @@ +## Light + +### Light Leak + +Multiple warm-colored overlays wash across frame. Needs: a flat warm tint layer + 2-3 bright radial gradient divs, all larger than the frame so edges are never visible. + +```js +// Warm tint washes over entire frame +tl.to("#leak-warm", { opacity: 0.4, duration: 0.3, ease: "power1.in" }, T); +// Bright leak elements drift in +tl.to("#leak-1", { opacity: 0.9, x: 300, duration: 0.5, ease: "sine.inOut" }, T + 0.05); +tl.to("#leak-2", { opacity: 0.8, x: 200, duration: 0.6, ease: "sine.inOut" }, T + 0.1); +// Peak warmth then swap +tl.to("#leak-warm", { opacity: 0.6, duration: 0.15, ease: "power2.in" }, T + 0.35); +tl.set(old, { opacity: 0 }, T + 0.45); +tl.set(new, { opacity: 1 }, T + 0.45); +// Leak fades +tl.to("#leak-warm", { opacity: 0, duration: 0.4, ease: "power2.out" }, T + 0.5); +tl.to("#leak-1", { opacity: 0, x: 600, duration: 0.35, ease: "power1.out" }, T + 0.5); +``` + +### Overexposure Burn + +Scene progressively blows out to white using CSS `filter: brightness()`, then white overlay fades in. Swap at peak white. White recedes to reveal new scene. + +```js +tl.to(old, { filter: "brightness(1.5)", scale: 1.03, duration: 0.2, ease: "power1.in" }, T); +tl.to(old, { filter: "brightness(3)", scale: 1.06, duration: 0.2, ease: "power2.in" }, T + 0.2); +tl.to("#flash-overlay", { opacity: 0.5, duration: 0.25, ease: "power1.in" }, T + 0.15); +tl.to("#flash-overlay", { opacity: 1, duration: 0.15, ease: "power2.in" }, T + 0.4); +tl.set(old, { opacity: 0, filter: "brightness(1)", scale: 1 }, T + 0.55); +tl.set(new, { opacity: 1 }, T + 0.55); +tl.to("#flash-overlay", { opacity: 0, duration: 0.35, ease: "power2.out" }, T + 0.55); +``` + +### Film Burn + +Staggered warm overlays (amber, orange, red) bleed from one edge. Each overlay is a large radial gradient div at high z-index. + +```js +tl.to("#burn-a", { opacity: 1, x: -300, duration: 0.4, ease: "power1.in" }, T); +tl.to("#burn-b", { opacity: 1, x: -500, duration: 0.5, ease: "power1.in" }, T + 0.05); +tl.to("#burn-c", { opacity: 1, x: -200, duration: 0.45, ease: "power1.in" }, T + 0.1); +tl.set(old, { opacity: 0 }, T + 0.35); +tl.set(new, { opacity: 1 }, T + 0.35); +tl.to("#burn-a", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.45); +tl.to("#burn-b", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.5); +tl.to("#burn-c", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.55); +``` diff --git a/.agents/skills/hyperframes/references/transitions/css-mechanical.md b/.agents/skills/hyperframes/references/transitions/css-mechanical.md new file mode 100644 index 0000000..fa119e7 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-mechanical.md @@ -0,0 +1,30 @@ +## Mechanical + +### Shutter + +Two full-screen halves close from top and bottom, meet in the middle. Swap while closed. Open again. + +```js +tl.to("#shutter-top", { y: 0, duration: 0.25, ease: "power3.in" }, T); +tl.to("#shutter-bot", { y: 0, duration: 0.25, ease: "power3.in" }, T); +tl.set(old, { opacity: 0 }, T + 0.25); +tl.set(new, { opacity: 1 }, T + 0.25); +tl.to("#shutter-top", { y: -540, duration: 0.25, ease: "power3.out" }, T + 0.3); +tl.to("#shutter-bot", { y: 540, duration: 0.25, ease: "power3.out" }, T + 0.3); +``` + +### Clock Wipe + +Radial polygon sweep stepping through quadrants. Use 9-point polygon with intermediate edge positions for smooth sweep. + +```js +tl.set(new, { opacity: 1, zIndex: 10 }, T); +var d = 0.1; // duration per quadrant +tl.set(new, { clipPath: "polygon(50% 50%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%)" }, T); +tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 50%, 100% 50%, 100% 50%, 100% 50%, 100% 50%)", duration: d, ease: "none" }, T); +tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%)", duration: d, ease: "none" }, T + d); +tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 0% 100%, 0% 50%, 0% 50%)", duration: d, ease: "none" }, T + d*2); +tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 0% 100%, 0% 50%, 0% 0%)", duration: d, ease: "none" }, T + d*3); +tl.set(new, { clipPath: "none", zIndex: "auto" }, T + d*4 + 0.02); +tl.set(old, { opacity: 0, zIndex: "auto" }, T + d*4 + 0.02); +``` diff --git a/.agents/skills/hyperframes/references/transitions/css-other.md b/.agents/skills/hyperframes/references/transitions/css-other.md new file mode 100644 index 0000000..698368a --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-other.md @@ -0,0 +1,25 @@ +## Other + +### Gravity Drop + +Old scene falls down with slight rotation. New scene was behind it. Needs z-index. + +```js +tl.set(new, { opacity: 1, zIndex: 1 }, T); +tl.set(old, { zIndex: 10 }, T); +tl.to(old, { y: 1200, rotation: 4, duration: 0.5, ease: "power3.in" }, T); +tl.set(old, { opacity: 0, zIndex: "auto" }, T + 0.5); +tl.set(new, { zIndex: "auto" }, T + 0.5); +``` + +### Morph Circle + +A circle scales up from center to fill frame (becoming the new scene's background color). New scene content fades in on top. + +```js +tl.set("#morph-circle", { background: newBgColor, opacity: 1, scale: 0 }, T); +tl.to("#morph-circle", { scale: 30, duration: 0.5, ease: "power3.in" }, T); +tl.set(old, { opacity: 0 }, T + 0.4); +tl.set(new, { opacity: 1 }, T + 0.4); +tl.to("#morph-circle", { opacity: 0, duration: 0.15, ease: "power2.out" }, T + 0.5); +``` diff --git a/.agents/skills/hyperframes/references/transitions/css-push.md b/.agents/skills/hyperframes/references/transitions/css-push.md new file mode 100644 index 0000000..b7f5503 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-push.md @@ -0,0 +1,41 @@ +## Linear / Push + +### Push Slide + +Both scenes move together — new pushes old out. + +```js +tl.to(old, { x: -1920, duration: 0.5, ease: "power3.inOut" }, T); +tl.fromTo(new, { x: 1920, opacity: 1 }, { x: 0, duration: 0.5, ease: "power3.inOut" }, T); +``` + +### Vertical Push + +Same as push slide but vertical. + +```js +tl.to(old, { y: -1080, duration: 0.5, ease: "power3.inOut" }, T); +tl.fromTo(new, { y: 1080, opacity: 1 }, { y: 0, duration: 0.5, ease: "power3.inOut" }, T); +``` + +### Elastic Push + +Push with overshoot bounce on the incoming scene. + +```js +tl.to(old, { x: -1920, duration: 0.5, ease: "power3.in" }, T); +tl.fromTo(new, { x: 1920, opacity: 1 }, { x: 30, duration: 0.4, ease: "power4.out" }, T + 0.1); +tl.to(new, { x: -15, duration: 0.15, ease: "sine.inOut" }, T + 0.5); +tl.to(new, { x: 0, duration: 0.1, ease: "sine.out" }, T + 0.65); +``` + +### Squeeze + +Old compresses, new expands from opposite side. + +```js +tl.to(old, { scaleX: 0, transformOrigin: "left center", duration: 0.4, ease: "power3.inOut" }, T); +tl.fromTo(new, { scaleX: 0, transformOrigin: "right center", opacity: 1 }, + { scaleX: 1, duration: 0.4, ease: "power3.inOut" }, T + 0.1); +tl.set(old, { opacity: 0 }, T + 0.5); +``` diff --git a/.agents/skills/hyperframes/references/transitions/css-radial.md b/.agents/skills/hyperframes/references/transitions/css-radial.md new file mode 100644 index 0000000..040dad2 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-radial.md @@ -0,0 +1,37 @@ +## Radial / Shape + +### Circle Iris + +Expanding circle from center reveals new scene. + +```js +tl.set(new, { opacity: 1 }, T); +tl.fromTo(new, + { clipPath: "circle(0% at 50% 50%)" }, + { clipPath: "circle(75% at 50% 50%)", duration: 0.5, ease: "power2.out" }, T); +tl.set(old, { opacity: 0 }, T + 0.5); +``` + +### Diamond Iris + +Expanding diamond shape from center. + +```js +tl.set(new, { opacity: 1 }, T); +tl.fromTo(new, + { clipPath: "polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)" }, + { clipPath: "polygon(50% -20%, 120% 50%, 50% 120%, -20% 50%)", duration: 0.5, ease: "power2.out" }, T); +tl.set(old, { opacity: 0 }, T + 0.5); +``` + +### Diagonal Split + +Old scene shrinks to a triangle in one corner. + +```js +tl.set(new, { opacity: 1, zIndex: 1 }, T); +tl.set(old, { zIndex: 10, clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)" }, T); +tl.to(old, { clipPath: "polygon(60% 0%, 100% 0%, 100% 40%, 60% 0%)", duration: 0.5, ease: "power3.inOut" }, T); +tl.set(old, { opacity: 0, zIndex: "auto", clipPath: "none" }, T + 0.5); +tl.set(new, { zIndex: "auto" }, T + 0.5); +``` diff --git a/.agents/skills/hyperframes/references/transitions/css-scale.md b/.agents/skills/hyperframes/references/transitions/css-scale.md new file mode 100644 index 0000000..b16e264 --- /dev/null +++ b/.agents/skills/hyperframes/references/transitions/css-scale.md @@ -0,0 +1,24 @@ +## Scale / Zoom + +### Zoom Through + +Old zooms past camera + blurs, new zooms in from behind. + +```js +tl.to(old, { scale: 2.5, opacity: 0, filter: "blur(8px)", duration: 0.4, ease: "power3.in" }, T); +tl.fromTo(new, + { scale: 0.5, opacity: 0, filter: "blur(8px)" }, + { scale: 1, opacity: 1, filter: "blur(0px)", duration: 0.4, ease: "power3.out" }, T + 0.15); +``` + +### Zoom Out + +Old shrinks away, new was behind it. Needs z-index management. + +```js +tl.set(new, { opacity: 1, zIndex: 1 }, T); +tl.set(old, { zIndex: 10, transformOrigin: "50% 50%" }, T); +tl.to(old, { scale: 0.3, opacity: 0, duration: 0.4, ease: "power3.in" }, T); +tl.set(old, { zIndex: "auto" }, T + 0.4); +tl.set(new, { zIndex: "auto" }, T + 0.4); +``` diff --git a/.agents/skills/hyperframes/references/tts.md b/.agents/skills/hyperframes/references/tts.md new file mode 100644 index 0000000..ee94993 --- /dev/null +++ b/.agents/skills/hyperframes/references/tts.md @@ -0,0 +1,56 @@ +# Text-to-Speech + +Generate speech audio locally using Kokoro-82M (no API key, runs on CPU). + +## Voice Selection + +Match voice to content. Default is `af_heart`. + +| Content type | Voice | Why | +| ------------- | --------------------- | -------------------------- | +| Product demo | `af_heart`/`af_nova` | Warm, professional | +| Tutorial | `am_adam`/`bf_emma` | Neutral, easy to follow | +| Marketing | `af_sky`/`am_michael` | Energetic or authoritative | +| Documentation | `bf_emma`/`bm_george` | Clear British English | +| Casual | `af_heart`/`af_sky` | Approachable, natural | + +Run `npx hyperframes tts --list` for all 54 voices (8 languages). + +## Speed Tuning + +- **0.7-0.8** — Tutorial, complex content +- **1.0** — Natural pace (default) +- **1.1-1.2** — Intros, upbeat content +- **1.5+** — Rarely appropriate + +## Usage + +```bash +npx hyperframes tts "Your script here" --voice af_nova --output narration.wav +npx hyperframes tts script.txt --voice bf_emma --output narration.wav +``` + +In compositions: + +```html + +``` + +## TTS + Captions Workflow + +```bash +npx hyperframes tts script.txt --voice af_heart --output narration.wav +npx hyperframes transcribe narration.wav # → transcript.json with word-level timestamps +``` + +## Requirements + +- Python 3.8+ with `kokoro-onnx` and `soundfile` +- Model downloads on first use (~311 MB + ~27 MB voices, cached in `~/.cache/hyperframes/tts/`) diff --git a/.agents/skills/hyperframes/references/typography.md b/.agents/skills/hyperframes/references/typography.md new file mode 100644 index 0000000..2a2ee52 --- /dev/null +++ b/.agents/skills/hyperframes/references/typography.md @@ -0,0 +1,175 @@ +# Typography + +The compiler embeds supported fonts — just write `font-family` in CSS. + +## Banned + +Training-data defaults that every LLM reaches for. These produce monoculture across compositions. + +Inter, Roboto, Open Sans, Noto Sans, Arimo, Lato, Source Sans, PT Sans, Nunito, Poppins, Outfit, Sora, Playfair Display, Cormorant Garamond, Bodoni Moda, EB Garamond, Cinzel, Prata, Syne + +**Syne in particular** is the most overused "distinctive" display font. It is an instant AI design tell. + +## Guardrails + +You know these rules but you violate them. Stop. + +- **Don't pair two sans-serifs.** You do this constantly — one for headlines, one for body. Cross the boundary: serif + sans, or sans + mono. +- **One expressive font per scene.** You pick two interesting fonts trying to make it "better." One performs, one recedes. +- **Weight contrast must be extreme.** You default to 400 vs 700. Video needs 300 vs 900. The difference must be visible in motion at a glance. +- **Video sizes, not web sizes.** Body: 20px minimum. Headlines: 60px+. Data labels: 16px. You will try to use 14px. Don't. + +## What You Don't Do Without Being Told + +- **Tension should mean something.** Don't pattern-match pairings. Ask WHY these two fonts disagree. The pairing should embody the content's contradiction — mechanical vs human, public vs private, institutional vs personal. If you can't articulate the tension, it's arbitrary. +- **Register switching.** Assign different fonts to different communicative modes — one voice for statements, another for data, another for attribution. Not hierarchy on a page. Voices in a conversation. +- **Tension can live inside a single font.** A font that looks familiar but is secretly strange creates tension with the viewer's expectations, not with another font. +- **One variable changed = dramatic contrast.** Same letterforms, monospaced vs proportional. Same family at different optical sizes. Changing only rhythm while everything else stays constant. +- **Double personality works.** Two expressive fonts can coexist if they share an attitude (both irreverent, both precise) even when their forms are completely different. +- **Time is hierarchy.** The first element to appear is the most important. In video, sequence replaces position. +- **Motion is typography.** How a word enters carries as much meaning as the font. A 0.1s slam vs a 2s fade — same font, completely different message. +- **Fixed reading time.** 3 seconds on screen = must be readable in 2. Fewer words, larger type. +- **Tracking tighter than web.** -0.03em to -0.05em on display sizes. Video encoding compresses letter detail. + +## Finding Fonts + +Don't default to what you know. If the content is luxury, a grotesque sans might create more tension than the expected Didone serif. Decide the register first, then search. + +Save this script to `/tmp/fontquery.py` and run with `curl -s 'https://fonts.google.com/metadata/fonts' > /tmp/gfonts.json && python3 /tmp/fontquery.py /tmp/gfonts.json`: + +```python +import json, sys, random +from collections import OrderedDict + +random.seed() # true random each run + +with open(sys.argv[1]) as f: + data = json.load(f) +fonts = data.get("familyMetadataList", []) + +ban = {"Inter","Roboto","Open Sans","Noto Sans","Lato","Poppins","Source Sans 3", + "PT Sans","Nunito","Outfit","Sora","Playfair Display","Cormorant Garamond", + "Bodoni Moda","EB Garamond","Cinzel","Prata","Arimo","Source Sans Pro","Syne"} +skip_pfx = ("Roboto","Noto ","Google Sans","Bpmf","Playwrite","Anek","BIZ ", + "Nanum","Shippori","Sawarabi","Zen ","Kaisei","Kiwi ","Yuji ","Radio ") + +def ok(f): + if f["family"] in ban: return False + if any(f["family"].startswith(b) for b in skip_pfx): return False + if "latin" not in (f.get("subsets") or []): return False + return True + +seen = set() +R = OrderedDict() + +# Trending Sans — recent (2022+), popular (<300) +R["Trending Sans"] = [] +for f in fonts: + if not ok(f) or f["family"] in seen: continue + if f.get("category") in ("Sans Serif","Display") and f.get("dateAdded","") >= "2022-01-01" and f.get("popularity",9999) < 300: + R["Trending Sans"].append(f); seen.add(f["family"]) + +# Trending Serif — recent (2018+), popular (<600) +R["Trending Serif"] = [] +for f in fonts: + if not ok(f) or f["family"] in seen: continue + if f.get("category") == "Serif" and f.get("dateAdded","") >= "2018-01-01" and f.get("popularity",9999) < 600: + R["Trending Serif"].append(f); seen.add(f["family"]) + +# Monospace — recent (2018+), popular (<600) +R["Monospace"] = [] +for f in fonts: + if not ok(f) or f["family"] in seen: continue + if f.get("category") == "Monospace" and f.get("dateAdded","") >= "2018-01-01" and f.get("popularity",9999) < 600: + R["Monospace"].append(f); seen.add(f["family"]) + +# Impact & Condensed — heavy display fonts with 800+ weight +R["Impact & Condensed"] = [] +for f in fonts: + if not ok(f) or f["family"] in seen: continue + has_heavy = any(k in list(f.get("fonts",{}).keys()) for k in ("800","900")) + is_display = f.get("category") in ("Sans Serif","Display") + if has_heavy and is_display and f.get("popularity",9999) < 400: + R["Impact & Condensed"].append(f); seen.add(f["family"]) + +# Script & Handwriting — popular (<300) +R["Script & Handwriting"] = [] +for f in fonts: + if not ok(f) or f["family"] in seen: continue + if f.get("category") == "Handwriting" and f.get("popularity",9999) < 300: + R["Script & Handwriting"].append(f); seen.add(f["family"]) + + +# Randomize the top 5 in each category so the LLM doesn't always pick the same first result +for cat in R: + R[cat].sort(key=lambda x: x.get("popularity",9999)) + top5 = R[cat][:5] + rest = R[cat][5:] + random.shuffle(top5) + R[cat] = top5 + rest +limits = {"Trending Sans":15,"Trending Serif":12,"Monospace":8, + "Impact & Condensed":12,"Script & Handwriting":10} +for cat in R: + items = R[cat][:limits.get(cat,10)] + if not items: continue + print(f"--- {cat} ({len(items)}) ---") + for ff in items: + var = "VAR" if ff.get("axes") else " " + print(f' {ff.get("popularity"):4d} | {var} | {ff["family"]}') + print() +``` + +Five categories: trending sans, trending serif, monospace, impact/condensed, script/handwriting. All dynamically filtered from Google Fonts metadata — no hardcoded font names. Cross classification boundaries when pairing. + +## Selection Thinking + +Don't pick fonts by category reflex (editorial → serif, tech → mono, modern → geometric sans). That's pattern matching, not design. + +1. **Name the register.** What voice is the content speaking in? Institutional authority? Personal confession? Technical precision? Casual irreverence? The register narrows the field more than the category. +2. **Think physically.** Imagine the font as a physical object the brand could ship — a museum exhibit caption, a hand-painted shop sign, a 1970s mainframe terminal manual, a fabric label inside a coat, a children's book printed on cheap newsprint, a tax form. Whichever physical object fits the register is pointing at the right _kind_ of typeface. +3. **Reject your first instinct.** The first font that feels right is usually your training-data default for that register. If you picked it last time too, find something else. +4. **Cross-check the assumption.** An editorial brief does NOT need a serif. A technical brief does NOT need a sans. A children's product does NOT need a rounded display font. The most distinctive choice often contradicts the category expectation. + +## Similar-Font Pairing + +Never pair two fonts that are similar but not identical — two geometric sans-serifs, two transitional serifs, two humanist sans. They create visual friction without clear hierarchy. The viewer senses something is "off" but can't articulate it. Either use one font at two weights, or pair fonts that contrast on multiple axes: serif + sans, condensed + wide, geometric + humanist. + +## Dark Backgrounds + +Light text on dark backgrounds creates two optical illusions you need to compensate for: + +- **Increased apparent weight.** Light-on-dark reads heavier than dark-on-light at the same `font-weight`. Use 350 instead of 400 for body text. Headlines are less affected because size compensates. +- **Tighter apparent spacing.** Light halos around letterforms reduce perceived gaps. Increase `line-height` by 0.05-0.1 beyond your light-background value. For display sizes, add 0.01em `letter-spacing` to counteract. + +## OpenType Features for Data + +Most fonts ship with OpenType features that are off by default. Turn them on for data compositions: + +```css +/* Tabular numbers — digits align vertically in columns */ +.stat-value, +.timer, +.data-column { + font-variant-numeric: tabular-nums; +} + +/* Diagonal fractions — renders 1/2 as ½ */ +.recipe-amount, +.ratio { + font-variant-numeric: diagonal-fractions; +} + +/* Small caps for abbreviations — less visual shouting */ +.abbreviation, +.unit { + font-variant-caps: all-small-caps; +} + +/* Disable ligatures in code — fi, fl, ffi should stay separate */ +code, +.code { + font-variant-ligatures: none; +} +``` + +`tabular-nums` is essential any time numbers are stacked vertically — stat callouts, timers, scoreboards, data tables. Without it, digits have proportional widths and columns don't align. diff --git a/.agents/skills/hyperframes/scripts/animation-map.mjs b/.agents/skills/hyperframes/scripts/animation-map.mjs new file mode 100644 index 0000000..55a9660 --- /dev/null +++ b/.agents/skills/hyperframes/scripts/animation-map.mjs @@ -0,0 +1,596 @@ +#!/usr/bin/env node +// animation-map.mjs — HyperFrames animation map for agents +// +// Reads every GSAP timeline registered in window.__timelines, enumerates +// tweens, samples bboxes at N points per tween, computes flags and +// human-readable summaries. Outputs a single animation-map.json. +// +// Usage: +// node skills/hyperframes/scripts/animation-map.mjs \ +// [--frames N] [--out ] [--min-duration S] [--width W] [--height H] [--fps N] + +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; + +import { + createFileServer, + createCaptureSession, + initializeSession, + closeCaptureSession, + getCompositionDuration, +} from "@hyperframes/producer"; + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +const args = parseArgs(process.argv.slice(2)); +if (!args.composition) die("missing "); + +const FRAMES = Number(args.frames ?? 6); +const OUT_DIR = resolve(args.out ?? ".hyperframes/anim-map"); +const MIN_DUR = Number(args["min-duration"] ?? 0.15); +const WIDTH = Number(args.width ?? 1920); +const HEIGHT = Number(args.height ?? 1080); +const FPS = Number(args.fps ?? 30); +const COMP_DIR = resolve(args.composition); + +await mkdir(OUT_DIR, { recursive: true }); + +// ─── Main ──────────────────────────────────────────────────────────────────── + +const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); +const session = await createCaptureSession( + server.url, + OUT_DIR, + { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, + null, +); +await initializeSession(session); + +try { + const duration = await getCompositionDuration(session); + const tweens = await enumerateTweens(session); + const kept = tweens.filter((tw) => tw.end - tw.start >= MIN_DUR); + + const report = { + composition: COMP_DIR, + duration, + totalTweens: tweens.length, + mappedTweens: kept.length, + skippedMicroTweens: tweens.length - kept.length, + tweens: [], + }; + + for (let i = 0; i < kept.length; i++) { + const tw = kept[i]; + const times = Array.from( + { length: FRAMES }, + (_, k) => +(tw.start + ((k + 0.5) / FRAMES) * (tw.end - tw.start)).toFixed(3), + ); + + const bboxes = []; + for (const t of times) { + await seekTo(session, t); + const bbox = await measureTarget(session, tw.selectorHint); + bboxes.push({ t, ...bbox }); + } + + const animProps = tw.props.filter( + (p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p), + ); + const flags = computeFlags(tw, bboxes, { width: WIDTH, height: HEIGHT }); + const summary = describeTween(tw, animProps, bboxes, flags); + + report.tweens.push({ + index: i + 1, + selector: tw.selectorHint, + targets: tw.targetCount, + props: animProps, + start: +tw.start.toFixed(3), + end: +tw.end.toFixed(3), + duration: +(tw.end - tw.start).toFixed(3), + ease: tw.ease, + bboxes, + flags, + summary, + }); + } + + markCollisions(report.tweens); + + for (const tw of report.tweens) { + if (tw.flags.includes("collision") && !tw.summary.includes("collision")) { + tw.summary += " Overlaps another animated element."; + } + } + + // ── Composition-level analysis ── + report.choreography = buildTimeline(report.tweens, duration); + report.density = computeDensity(report.tweens, duration); + report.staggers = detectStaggers(report.tweens); + report.elements = buildElementLifecycles(report.tweens); + report.deadZones = findDeadZones(report.density, duration); + report.snapshots = await captureSnapshots(session, report.tweens, duration); + + await writeFile(join(OUT_DIR, "animation-map.json"), JSON.stringify(report, null, 2)); + + printSummary(report); +} finally { + await closeCaptureSession(session).catch(() => {}); + server.close(); +} + +// ─── Seek helper ──────────────────────────────────────────────────────────── + +async function seekTo(session, t) { + await session.page.evaluate((time) => { + if (window.__hf && typeof window.__hf.seek === "function") { + window.__hf.seek(time); + return; + } + const tls = window.__timelines; + if (tls) { + for (const tl of Object.values(tls)) { + if (typeof tl.seek === "function") tl.seek(time); + } + } + }, t); + await new Promise((r) => setTimeout(r, 100)); +} + +// ─── Timeline introspection ────────────────────────────────────────────────── + +async function enumerateTweens(session) { + return await session.page.evaluate(() => { + const results = []; + const registry = window.__timelines || {}; + + const selectorOf = (el) => { + if (!el || !(el instanceof Element)) return null; + if (el.id) return `#${el.id}`; + const cls = [...el.classList].slice(0, 2).join("."); + return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase(); + }; + + const walk = (node, parentOffset = 0) => { + if (!node) return; + if (typeof node.getChildren === "function") { + const offset = parentOffset + (node.startTime?.() ?? 0); + for (const child of node.getChildren(true, true, true)) { + walk(child, offset); + } + return; + } + const targets = (node.targets?.() ?? []).filter((t) => t instanceof Element); + if (!targets.length) return; + const vars = node.vars ?? {}; + const props = Object.keys(vars).filter( + (k) => + ![ + "duration", + "ease", + "delay", + "repeat", + "yoyo", + "onStart", + "onUpdate", + "onComplete", + "stagger", + ].includes(k), + ); + const start = parentOffset + (node.startTime?.() ?? 0); + const end = start + (node.duration?.() ?? 0); + results.push({ + selectorHint: selectorOf(targets[0]) ?? "(unknown)", + targetCount: targets.length, + props, + start, + end, + ease: typeof vars.ease === "string" ? vars.ease : (vars.ease?.toString?.() ?? "none"), + }); + }; + + for (const tl of Object.values(registry)) walk(tl, 0); + results.sort((a, b) => a.start - b.start); + return results; + }); +} + +async function measureTarget(session, selector) { + return await session.page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) return { x: 0, y: 0, w: 0, h: 0, missing: true }; + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + return { + x: Math.round(r.x), + y: Math.round(r.y), + w: Math.round(r.width), + h: Math.round(r.height), + opacity: parseFloat(cs.opacity), + visible: cs.visibility !== "hidden" && cs.display !== "none", + }; + }, selector); +} + +// ─── Tween description (the key output for agents) ────────────────────────── + +function describeTween(tw, props, bboxes, flags) { + const dur = (tw.end - tw.start).toFixed(2); + const parts = []; + + parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`); + + // Movement + const first = bboxes[0]; + const last = bboxes[bboxes.length - 1]; + if (first && last) { + const dx = last.x - first.x; + const dy = last.y - first.y; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + const dirs = []; + if (Math.abs(dy) > 3) dirs.push(dy < 0 ? `${Math.abs(dy)}px up` : `${Math.abs(dy)}px down`); + if (Math.abs(dx) > 3) + dirs.push(dx < 0 ? `${Math.abs(dx)}px left` : `${Math.abs(dx)}px right`); + parts.push(`moves ${dirs.join(" and ")}`); + } + } + + // Opacity + if (first && last && first.opacity !== undefined && last.opacity !== undefined) { + const o1 = first.opacity; + const o2 = last.opacity; + if (Math.abs(o2 - o1) > 0.1) { + if (o1 < 0.1 && o2 > 0.5) parts.push("fades in"); + else if (o1 > 0.5 && o2 < 0.1) parts.push("fades out"); + else parts.push(`opacity ${o1.toFixed(1)}→${o2.toFixed(1)}`); + } + } + + // Scale (from props) + if (props.includes("scale") || props.includes("scaleX") || props.includes("scaleY")) { + parts.push("scales"); + } + + // Size changes + if (first && last) { + const dw = last.w - first.w; + const dh = last.h - first.h; + if (Math.abs(dw) > 5) parts.push(`width ${first.w}→${last.w}px`); + if (Math.abs(dh) > 5) parts.push(`height ${first.h}→${last.h}px`); + } + + // Visibility + if (first && last && first.visible !== last.visible) { + parts.push(last.visible ? "becomes visible" : "becomes hidden"); + } + + // Final position + if (last && !last.missing) { + parts.push(`ends at (${last.x}, ${last.y}) ${last.w}×${last.h}px`); + } + + // Flags + if (flags.length > 0) { + parts.push(`FLAGS: ${flags.join(", ")}`); + } + + return parts.join(". ") + "."; +} + +// ─── Flag computation ─────────────────────────────────────────────────────── + +function computeFlags(tw, bboxes, { width, height }) { + const flags = []; + const dur = tw.end - tw.start; + + if (bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate"); + + const anyOffscreen = bboxes.some( + (b) => + b.x + b.w <= 0 || + b.y + b.h <= 0 || + b.x >= width || + b.y >= height || + b.x < -b.w * 0.5 || + b.y < -b.h * 0.5 || + b.x + b.w > width + b.w * 0.5 || + b.y + b.h > height + b.h * 0.5, + ); + if (anyOffscreen) flags.push("offscreen"); + + if (bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible)) { + flags.push("invisible"); + } + + if (dur < 0.2 && tw.props.some((p) => ["y", "x", "opacity", "scale"].includes(p))) { + flags.push("paced-fast"); + } + if (dur > 2.0) flags.push("paced-slow"); + + return flags; +} + +function markCollisions(tweens) { + for (let i = 0; i < tweens.length; i++) { + for (let j = i + 1; j < tweens.length; j++) { + const a = tweens[i]; + const b = tweens[j]; + if (a.end <= b.start || b.end <= a.start) continue; + for (const ba of a.bboxes) { + const bb = b.bboxes.find((x) => Math.abs(x.t - ba.t) < 0.05); + if (!bb) continue; + const overlap = rectOverlapArea(ba, bb); + const aArea = ba.w * ba.h; + if (aArea > 0 && overlap / aArea > 0.3) { + if (!a.flags.includes("collision")) a.flags.push("collision"); + if (!b.flags.includes("collision")) b.flags.push("collision"); + break; + } + } + } + } +} + +function rectOverlapArea(a, b) { + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.w, b.x + b.w); + const y2 = Math.min(a.y + a.h, b.y + b.h); + return Math.max(0, x2 - x1) * Math.max(0, y2 - y1); +} + +// ─── Composition-level analysis ───────────────────────────────────────────── + +function buildTimeline(tweens, duration) { + const cols = 60; + const lines = []; + const secPerCol = duration / cols; + + lines.push("Timeline (" + duration.toFixed(1) + "s, each char ≈ " + secPerCol.toFixed(2) + "s):"); + lines.push(" " + "0s" + " ".repeat(cols - 8) + duration.toFixed(0) + "s"); + lines.push(" " + "┼" + "─".repeat(cols - 1) + "┤"); + + for (const tw of tweens) { + const startCol = Math.floor(tw.start / secPerCol); + const endCol = Math.min(cols, Math.ceil(tw.end / secPerCol)); + const bar = + " ".repeat(startCol) + + "█".repeat(Math.max(1, endCol - startCol)) + + " ".repeat(Math.max(0, cols - endCol)); + const label = tw.selector + " " + tw.props.join("+"); + lines.push(" " + bar + " " + label); + } + + return lines.join("\n"); +} + +function computeDensity(tweens, duration) { + const buckets = []; + for (let t = 0; t < duration; t += 0.5) { + const active = tweens.filter((tw) => tw.start <= t + 0.5 && tw.end >= t); + buckets.push({ t: +t.toFixed(1), activeTweens: active.length }); + } + return buckets; +} + +function findDeadZones(density, duration) { + const zones = []; + let zoneStart = null; + for (const d of density) { + if (d.activeTweens === 0) { + if (zoneStart === null) zoneStart = d.t; + } else { + if (zoneStart !== null) { + const zoneEnd = d.t; + if (zoneEnd - zoneStart >= 1.0) { + zones.push({ + start: zoneStart, + end: zoneEnd, + duration: +(zoneEnd - zoneStart).toFixed(1), + note: + "No animation for " + + (zoneEnd - zoneStart).toFixed(1) + + "s. Intentional hold or missing entrance?", + }); + } + zoneStart = null; + } + } + } + if (zoneStart !== null && duration - zoneStart >= 1.0) { + zones.push({ + start: zoneStart, + end: +duration.toFixed(1), + duration: +(duration - zoneStart).toFixed(1), + note: + "No animation for " + + (duration - zoneStart).toFixed(1) + + "s at end. Final hold or missing outro?", + }); + } + return zones; +} + +function detectStaggers(tweens) { + const groups = []; + const used = new Set(); + + for (let i = 0; i < tweens.length; i++) { + if (used.has(i)) continue; + const tw = tweens[i]; + const group = [tw]; + used.add(i); + + for (let j = i + 1; j < tweens.length; j++) { + if (used.has(j)) continue; + const other = tweens[j]; + const sameProps = tw.props.join(",") === other.props.join(","); + const sameDuration = Math.abs(tw.duration - other.duration) < 0.05; + const closeInTime = other.start - tw.start < tw.duration * 4; + if (sameProps && sameDuration && closeInTime) { + group.push(other); + used.add(j); + } + } + + if (group.length >= 3) { + const intervals = []; + for (let k = 1; k < group.length; k++) { + intervals.push(+(group[k].start - group[k - 1].start).toFixed(3)); + } + const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length; + const maxDrift = Math.max(...intervals.map((iv) => Math.abs(iv - avgInterval))); + const consistent = maxDrift < avgInterval * 0.3; + + groups.push({ + elements: group.map((g) => g.selector), + props: tw.props, + count: group.length, + intervals, + avgInterval: +avgInterval.toFixed(3), + consistent, + note: consistent + ? group.length + + " elements stagger at " + + (avgInterval * 1000).toFixed(0) + + "ms intervals" + : group.length + + " elements stagger with uneven intervals (" + + intervals.map((iv) => (iv * 1000).toFixed(0) + "ms").join(", ") + + ")", + }); + } + } + + return groups; +} + +function buildElementLifecycles(tweens) { + const elements = {}; + for (const tw of tweens) { + const sel = tw.selector; + if (!elements[sel]) { + elements[sel] = { firstTween: tw.start, lastTween: tw.end, tweenCount: 0, props: new Set() }; + } + elements[sel].firstTween = Math.min(elements[sel].firstTween, tw.start); + elements[sel].lastTween = Math.max(elements[sel].lastTween, tw.end); + elements[sel].tweenCount++; + tw.props.forEach((p) => elements[sel].props.add(p)); + } + + const result = {}; + for (const [sel, data] of Object.entries(elements)) { + const lastBbox = findLastBbox(tweens, sel); + result[sel] = { + firstAppears: +data.firstTween.toFixed(3), + lastAnimates: +data.lastTween.toFixed(3), + tweenCount: data.tweenCount, + props: [...data.props], + endsVisible: lastBbox ? lastBbox.opacity > 0.1 && lastBbox.visible : null, + finalPosition: lastBbox + ? { x: lastBbox.x, y: lastBbox.y, w: lastBbox.w, h: lastBbox.h } + : null, + }; + } + return result; +} + +function findLastBbox(tweens, selector) { + for (let i = tweens.length - 1; i >= 0; i--) { + if (tweens[i].selector === selector && tweens[i].bboxes?.length > 0) { + return tweens[i].bboxes[tweens[i].bboxes.length - 1]; + } + } + return null; +} + +async function captureSnapshots(session, tweens, duration) { + const times = [0, duration * 0.25, duration * 0.5, duration * 0.75, duration - 0.1]; + const snapshots = []; + + for (const t of times) { + await seekTo(session, t); + const visible = await session.page.evaluate(() => { + const out = []; + const els = document.querySelectorAll("[id]"); + for (const el of els) { + const cs = getComputedStyle(el); + if (cs.display === "none") continue; + const opacity = parseFloat(cs.opacity); + if (opacity < 0.01) continue; + const rect = el.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) continue; + out.push({ + id: el.id, + x: Math.round(rect.x), + y: Math.round(rect.y), + w: Math.round(rect.width), + h: Math.round(rect.height), + opacity: +opacity.toFixed(2), + }); + } + return out; + }); + + const activeTweens = tweens + .filter((tw) => tw.start <= t && tw.end >= t) + .map((tw) => tw.selector); + + snapshots.push({ + t: +t.toFixed(2), + visibleElements: visible.length, + animatingNow: activeTweens, + elements: visible, + }); + } + + return snapshots; +} + +// ─── Output ───────────────────────────────────────────────────────────────── + +function printSummary(report) { + console.log( + `\nAnimation map: ${report.mappedTweens}/${report.totalTweens} tweens (skipped ${report.skippedMicroTweens} micro-tweens)`, + ); + + const flagCounts = {}; + for (const tw of report.tweens) { + for (const f of tw.flags) flagCounts[f] = (flagCounts[f] ?? 0) + 1; + } + if (Object.keys(flagCounts).length > 0) { + for (const [f, n] of Object.entries(flagCounts)) console.log(` ${f}: ${n}`); + } + if (report.staggers?.length > 0) { + console.log(` staggers: ${report.staggers.map((s) => s.note).join("; ")}`); + } + if (report.deadZones?.length > 0) { + console.log( + ` dead zones: ${report.deadZones.map((z) => z.start + "-" + z.end + "s").join(", ")}`, + ); + } + + console.log(report.choreography); +} + +function parseArgs(argv) { + const out = {}; + let positional = 0; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const k = a.slice(2); + const v = argv[i + 1]?.startsWith("--") ? true : argv[++i]; + out[k] = v; + } else if (positional === 0) { + out.composition = a; + positional++; + } + } + return out; +} + +function die(msg) { + console.error(`animation-map: ${msg}`); + process.exit(2); +} diff --git a/.agents/skills/hyperframes/scripts/contrast-report.mjs b/.agents/skills/hyperframes/scripts/contrast-report.mjs new file mode 100644 index 0000000..63fa625 --- /dev/null +++ b/.agents/skills/hyperframes/scripts/contrast-report.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node +// contrast-report.mjs — HyperFrames contrast audit +// +// Reads a composition, seeks to N sample timestamps, walks the DOM for text +// elements, measures the WCAG 2.1 contrast ratio between each element's +// declared foreground color and the pixels behind it, and emits: +// +// - contrast-report.json (machine-readable, one entry per text element × sample) +// - contrast-overlay.png (sprite grid; magenta=fail AA, yellow=pass AA only, green=AAA) +// +// Usage: +// node skills/hyperframes/scripts/contrast-report.mjs \ +// [--samples N] [--out ] [--width W] [--height H] [--fps N] +// +// The composition directory must contain an index.html. Raw authoring HTML +// works — the producer's file server auto-injects the runtime at serve time. +// Exits 1 if any text element fails WCAG AA. + +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import sharp from "sharp"; + +// Use the producer's file server — it auto-injects the HyperFrames runtime +// and render-seek bridge, so raw authoring HTML works without a build step. +import { + createFileServer, + createCaptureSession, + initializeSession, + closeCaptureSession, + captureFrameToBuffer, + getCompositionDuration, +} from "@hyperframes/producer"; + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +const args = parseArgs(process.argv.slice(2)); +if (!args.composition) die("missing "); + +const SAMPLES = Number(args.samples ?? 10); +const OUT_DIR = resolve(args.out ?? ".hyperframes/contrast"); +const WIDTH = Number(args.width ?? 1920); +const HEIGHT = Number(args.height ?? 1080); +const FPS = Number(args.fps ?? 30); +const COMP_DIR = resolve(args.composition); + +// ─── Main ──────────────────────────────────────────────────────────────────── + +await mkdir(OUT_DIR, { recursive: true }); + +const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); +const session = await createCaptureSession( + server.url, + OUT_DIR, + { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, + null, +); +await initializeSession(session); + +try { + const duration = await getCompositionDuration(session); + const times = Array.from( + { length: SAMPLES }, + (_, i) => +(((i + 0.5) / SAMPLES) * duration).toFixed(3), + ); + + const allEntries = []; + const overlayFrames = []; + + for (let i = 0; i < times.length; i++) { + const t = times[i]; + const { buffer: pngBuf } = await captureFrameToBuffer(session, i, t); + const elements = await probeTextElements(session, t); + const annotated = await annotateFrame(pngBuf, elements); + overlayFrames.push({ t, png: annotated }); + for (const el of elements) allEntries.push({ time: t, ...el }); + } + + const report = { + composition: COMP_DIR, + width: WIDTH, + height: HEIGHT, + duration, + samples: times, + entries: allEntries, + summary: summarize(allEntries), + }; + + await writeFile(resolve(OUT_DIR, "contrast-report.json"), JSON.stringify(report, null, 2)); + await writeOverlaySprite(overlayFrames, resolve(OUT_DIR, "contrast-overlay.png")); + + printSummary(report); + process.exitCode = report.summary.failAA > 0 ? 1 : 0; +} finally { + await closeCaptureSession(session).catch(() => {}); + server.close(); +} + +// ─── DOM probe (runs in the page) ──────────────────────────────────────────── + +async function probeTextElements(session, _t) { + // `session.page` is the Puppeteer Page owned by the capture session. + // We pass a pure function to `evaluate`: it walks the DOM and returns + // enough info for us to compute a ratio in Node using the frame buffer. + return await session.page.evaluate(() => { + /** @type {Array<{selector: string, text: string, fg: [number,number,number,number], fontSize: number, fontWeight: number, bbox: {x:number,y:number,w:number,h:number}}>} */ + const out = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); + const parseColor = (c) => { + const m = c.match(/rgba?\(([^)]+)\)/); + if (!m) return [0, 0, 0, 1]; + const parts = m[1].split(",").map((s) => parseFloat(s.trim())); + return [parts[0], parts[1], parts[2], parts[3] ?? 1]; + }; + const selectorOf = (el) => { + if (el.id) return `#${el.id}`; + const cls = [...el.classList].slice(0, 2).join("."); + return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase(); + }; + let el; + while ((el = walker.nextNode())) { + // must have direct text + const direct = [...el.childNodes].some( + (n) => n.nodeType === 3 && n.textContent.trim().length, + ); + if (!direct) continue; + const cs = getComputedStyle(el); + if (cs.visibility === "hidden" || cs.display === "none") continue; + if (parseFloat(cs.opacity) <= 0.01) continue; + const rect = el.getBoundingClientRect(); + if (rect.width < 8 || rect.height < 8) continue; + out.push({ + selector: selectorOf(el), + text: el.textContent.trim().slice(0, 60), + fg: parseColor(cs.color), + fontSize: parseFloat(cs.fontSize), + fontWeight: Number(cs.fontWeight) || 400, + bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height }, + }); + } + return out; + }); +} + +// ─── Pixel sampling + WCAG math ────────────────────────────────────────────── + +async function annotateFrame(pngBuf, elements) { + const img = sharp(pngBuf); + const meta = await img.metadata(); + const { width, height } = meta; + const raw = await img.ensureAlpha().raw().toBuffer(); + const channels = 4; + + const measured = []; + for (const el of elements) { + const bg = sampleRingMedian(raw, width, height, channels, el.bbox); + const fg = compositeOver(el.fg, bg); // flatten any alpha against measured bg + const ratio = wcagRatio(fg, bg); + const large = isLargeText(el.fontSize, el.fontWeight); + el.bg = bg; + el.ratio = +ratio.toFixed(2); + el.wcagAA = large ? ratio >= 3 : ratio >= 4.5; + el.wcagAALarge = ratio >= 3; + el.wcagAAA = large ? ratio >= 4.5 : ratio >= 7; + measured.push(el); + } + + // Draw boxes + ratio labels as an SVG overlay (sharp composite). + const svg = buildOverlaySVG(measured, width, height); + return await sharp(pngBuf) + .composite([{ input: Buffer.from(svg), top: 0, left: 0 }]) + .png() + .toBuffer(); +} + +function sampleRingMedian(raw, width, height, channels, bbox) { + // 4-px ring immediately outside the element bbox. Median of each channel. + const r = [], + g = [], + b = []; + const x0 = Math.max(0, Math.floor(bbox.x) - 4); + const x1 = Math.min(width - 1, Math.ceil(bbox.x + bbox.w) + 4); + const y0 = Math.max(0, Math.floor(bbox.y) - 4); + const y1 = Math.min(height - 1, Math.ceil(bbox.y + bbox.h) + 4); + const pushPixel = (x, y) => { + const i = (y * width + x) * channels; + r.push(raw[i]); + g.push(raw[i + 1]); + b.push(raw[i + 2]); + }; + for (let x = x0; x <= x1; x++) { + pushPixel(x, y0); + pushPixel(x, y1); + } + for (let y = y0; y <= y1; y++) { + pushPixel(x0, y); + pushPixel(x1, y); + } + return [median(r), median(g), median(b), 1]; +} + +function median(arr) { + const s = [...arr].sort((a, b) => a - b); + return s[Math.floor(s.length / 2)]; +} + +function compositeOver([fr, fg, fb, fa], [br, bg, bb]) { + return [ + Math.round(fr * fa + br * (1 - fa)), + Math.round(fg * fa + bg * (1 - fa)), + Math.round(fb * fa + bb * (1 - fa)), + 1, + ]; +} + +function relLum([r, g, b]) { + const ch = (v) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b); +} + +function wcagRatio(a, b) { + const la = relLum(a); + const lb = relLum(b); + const [L1, L2] = la > lb ? [la, lb] : [lb, la]; + return (L1 + 0.05) / (L2 + 0.05); +} + +function isLargeText(fontSize, fontWeight) { + return fontSize >= 24 || (fontSize >= 19 && fontWeight >= 700); +} + +// ─── Overlay rendering ─────────────────────────────────────────────────────── + +function buildOverlaySVG(elements, w, h) { + const rects = elements + .map((el) => { + const color = !el.wcagAA ? "#ff00aa" : !el.wcagAAA ? "#ffcc00" : "#00e08a"; + const { x, y, w: bw, h: bh } = el.bbox; + return ` + + + + ${el.ratio.toFixed(1)}:1 + `; + }) + .join(""); + return `${rects}`; +} + +async function writeOverlaySprite(frames, outPath) { + if (!frames.length) return; + const cols = Math.min(frames.length, 5); + const rows = Math.ceil(frames.length / cols); + const { width, height } = await sharp(frames[0].png).metadata(); + const scale = 0.25; + const cellW = Math.round(width * scale); + const cellH = Math.round(height * scale); + + const cells = await Promise.all( + frames.map(async (f) => ({ + input: await sharp(f.png).resize(cellW, cellH).png().toBuffer(), + time: f.t, + })), + ); + + const composites = cells.map((c, i) => ({ + input: c.input, + top: Math.floor(i / cols) * cellH, + left: (i % cols) * cellW, + })); + + await sharp({ + create: { + width: cols * cellW, + height: rows * cellH, + channels: 3, + background: { r: 16, g: 16, b: 20 }, + }, + }) + .composite(composites) + .png() + .toFile(outPath); +} + +// ─── Summary ──────────────────────────────────────────────────────────────── + +function summarize(entries) { + const total = entries.length; + const failAA = entries.filter((e) => !e.wcagAA).length; + const passAAonly = entries.filter((e) => e.wcagAA && !e.wcagAAA).length; + const passAAA = entries.filter((e) => e.wcagAAA).length; + return { total, failAA, passAAonly, passAAA }; +} + +function printSummary({ summary, entries }) { + const { total, failAA, passAAonly, passAAA } = summary; + console.log(`\nContrast report: ${total} text-element samples`); + console.log(` fail WCAG AA: ${failAA}`); + console.log(` pass AA, not AAA: ${passAAonly}`); + console.log(` pass AAA: ${passAAA}`); + if (failAA) { + console.log("\nFailures:"); + for (const e of entries.filter((x) => !x.wcagAA)) { + console.log(` t=${e.time}s ${e.selector.padEnd(24)} ${e.ratio.toFixed(2)}:1 "${e.text}"`); + } + } +} + +// ─── Utilities ────────────────────────────────────────────────────────────── + +function parseArgs(argv) { + const out = {}; + let positional = 0; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const k = a.slice(2); + const v = argv[i + 1]?.startsWith("--") ? true : argv[++i]; + out[k] = v; + } else if (positional === 0) { + out.composition = a; + positional++; + } + } + return out; +} + +function die(msg) { + console.error(`contrast-report: ${msg}`); + process.exit(2); +} diff --git a/.agents/skills/hyperframes/visual-styles.md b/.agents/skills/hyperframes/visual-styles.md new file mode 100644 index 0000000..d1e8025 --- /dev/null +++ b/.agents/skills/hyperframes/visual-styles.md @@ -0,0 +1,211 @@ +# Visual Style Library + +Named visual identities for HyperFrames videos. Each style is grounded in a real graphic design tradition. Use them to give your video a specific visual personality, not just generic "clean" or "bold." + +**How to pick:** Match mood first, content second. Ask: _"What should the viewer FEEL?"_ + +**How to use:** Reference the style in your scene plan. Translate the style's principles into concrete composition decisions — palette choice, font selection, entrance patterns, transition type, ambient motion feel. + +## Quick Reference + +| Style | Mood | Best for | Primary shader | +| --------------- | --------------------- | ---------------------------------- | --------------------------------- | +| Swiss Pulse | Clinical, precise | SaaS, data, dev tools, metrics | Cinematic Zoom or SDF Iris | +| Velvet Standard | Premium, timeless | Luxury, enterprise, keynotes | Cross-Warp Morph | +| Deconstructed | Industrial, raw | Tech launches, security, punk | Glitch or Whip Pan | +| Maximalist Type | Loud, kinetic | Big announcements, launches | Ridged Burn | +| Data Drift | Futuristic, immersive | AI, ML, cutting-edge tech | Gravitational Lens or Domain Warp | +| Soft Signal | Intimate, warm | Wellness, personal stories, brand | Thermal Distortion | +| Folk Frequency | Cultural, vivid | Consumer apps, food, communities | Swirl Vortex or Ripple Waves | +| Shadow Cut | Dark, cinematic | Dramatic reveals, security, exposé | Domain Warp | + +--- + +## 1. Swiss Pulse — Josef Müller-Brockmann + +**Mood:** Clinical, precise | **Best for:** SaaS dashboards, developer tools, APIs, metrics + +- Black (`#1a1a1a`), white, ONE accent — electric blue (`#0066FF`) or amber (`#FFB300`) +- Helvetica or Inter Bold for headlines, Regular for labels. Numbers large (80–120px) +- Grid-locked compositions. Every element snaps to an invisible 12-column grid +- Animated counters count up from 0. Hard cuts, no decorative transitions +- Transitions: Cinematic Zoom or SDF Iris (precise, geometric) + +**GSAP signature:** `expo.out`, `power4.out`. Entries are fast and snap into place. Nothing floats. + +``` +Swiss Pulse: Black/white + one electric accent. Grid-locked compositions. +Numbers dominate the frame at 80-120px. Counter animations from 0. +Hard cuts or geometric transitions. Nothing decorative. +``` + +--- + +## 2. Velvet Standard — Massimo Vignelli + +**Mood:** Premium, timeless | **Best for:** Luxury products, enterprise software, keynotes, investor decks + +- Black, white, ONE rich accent — deep navy (`#1a237e`) or gold (`#c9a84c`) +- Thin sans-serif, ALL CAPS, wide letter-spacing (`0.15em+`) +- Generous negative space. Symmetrical, centered, architectural precision +- Slow, deliberate. Sequential reveals with long holds. No frantic motion +- Transitions: Cross-Warp Morph (elegant, organic flow between scenes) + +**GSAP signature:** `sine.inOut`, `power1`. Nothing snaps — everything glides with intention. + +``` +Velvet Standard: Black, white, one rich accent. Thin ALL CAPS type with wide tracking. +Generous negative space. Sequential reveals, long holds. +Cross-Warp Morph transitions. Slow and deliberate — luxury takes its time. +``` + +--- + +## 3. Deconstructed — Neville Brody + +**Mood:** Industrial, raw | **Best for:** Tech news, developer launches, security products, punk-energy reveals + +- Dark grey (`#1a1a1a`), rust orange (`#D4501E`), raw white (`#f0f0f0`) +- Type at angles, overlapping edges, escaping frames. Bold industrial weight +- Gritty textures: scan-line effects, glitch artifacts baked into the design +- Text SLAMS and SHATTERS. Letters scramble then snap to final position +- Transitions: Glitch shader or Whip Pan (breaks the rules, feels aggressive) + +**GSAP signature:** `back.out(2.5)`, `steps(8)`, `elastic.out(1.2, 0.4)`. Intentional irregularity. + +``` +Deconstructed: Dark grey #1a1a1a + rust orange #D4501E. Type at angles, escaping frames. +Scan-line glitch overlays. Text SLAMS and scrambles into place. +Glitch shader transitions. Industrial and raw — nothing should feel polished. +``` + +--- + +## 4. Maximalist Type — Paula Scher + +**Mood:** Loud, kinetic | **Best for:** Big product launches, milestone announcements, high-energy hype videos + +- Bold saturated: red (`#E63946`), yellow (`#FFD60A`), black, white — maximum contrast +- Text IS the visual. Overlapping type layers at different scales and angles, filling 50–80% of frame +- Everything is kinetic: slamming, sliding, scaling. 2–3 second rapid-fire scenes +- Text layered OVER footage — never empty backgrounds +- Transitions: Ridged Burn (explosive, dramatic, impossible to ignore) + +**GSAP signature:** `expo.out`, `back.out(1.8)`. Fast arrivals, hard stops. + +``` +Maximalist Type: Red, yellow, black, white — max contrast. Text IS the visual. +Overlapping at different scales, 50-80% of frame. Everything in motion. +Ridged Burn transitions. No static moments — kinetic energy throughout. +``` + +--- + +## 5. Data Drift — Refik Anadol + +**Mood:** Futuristic, immersive | **Best for:** AI products, ML platforms, data companies, speculative tech + +- Iridescent: deep black (`#0a0a0a`), electric purple (`#7c3aed`), cyan (`#06b6d4`) +- Thin futuristic sans-serif — floating, weightless, minimal +- Fluid morphing compositions. Extreme scale shifts (micro → macro) +- Particles coalesce into numbers. Light traces data paths through the frame +- Transitions: Gravitational Lens or Domain Warp (otherworldly distortion) + +**GSAP signature:** `sine.inOut`, `power2.out`. Smooth, continuous, organic. Nothing hard. + +``` +Data Drift: Deep black #0a0a0a with electric purple #7c3aed and cyan #06b6d4. +Thin futuristic type, minimal text. Particles coalesce into numbers. +Gravitational Lens or Domain Warp transitions. Fluid, immersive, otherworldly. +``` + +--- + +## 6. Soft Signal — Stefan Sagmeister + +**Mood:** Intimate, warm | **Best for:** Wellness brands, personal stories, lifestyle products, human-centered apps + +- Warm amber (`#F5A623`), cream (`#FFF8EC`), dusty rose (`#C4A3A3`), sage green (`#8FAF8C`) +- Handwritten-style or humanist serif fonts. Personal, lowercase, delicate +- Close-up framing feel: single element fills the frame. Nothing feels corporate +- Slow drifts and floats, never snaps. Soft organic motion throughout +- Transitions: Thermal Distortion (warm, flowing, like heat shimmer) + +**GSAP signature:** `sine.inOut`, `power1.inOut`. Everything breathes. + +``` +Soft Signal: Warm amber, cream, dusty rose, sage green. Humanist or handwritten type. +Single elements fill the frame — intimate, never corporate. +Slow drifts and floats throughout. Thermal Distortion transitions. +Nothing should feel hurried or polished. +``` + +--- + +## 7. Folk Frequency — Eduardo Terrazas + +**Mood:** Cultural, vivid | **Best for:** Consumer apps, food platforms, community products, festive launches + +- Vivid folk: hot pink (`#FF1493`), cobalt blue (`#0047AB`), sun yellow (`#FFE000`), emerald (`#009B77`) +- Bold warm rounded type. Pattern and repetition — folk art rhythm and density +- Layered compositions with rich visual texture. Every frame feels handcrafted +- Colorful motion: elements bounce, pop, and spin into place with joy +- Transitions: Swirl Vortex or Ripple Waves (hypnotic, celebratory) + +**GSAP signature:** `back.out(1.6)`, `elastic.out(1, 0.5)`. Overshoots feel intentional. + +``` +Folk Frequency: Hot pink #FF1493, cobalt blue, sun yellow, emerald. Bold rounded type. +Pattern and repetition throughout. Layered, dense, handcrafted feeling. +Swirl Vortex or Ripple Waves transitions. Joyful, celebratory energy. +``` + +--- + +## 8. Shadow Cut — Hans Hillmann + +**Mood:** Dark, cinematic | **Best for:** Security products, dramatic reveals, investigative content, intense launches + +- Near-monochrome: deep blacks (`#0a0a0a`), cold greys (`#3a3a3a`), stark white + blood red (`#C1121F`) or toxic green (`#39FF14`) +- Sharp angular text like film noir title cards. Heavy contrast, no softness +- Heavy shadow — elements emerge from darkness. Reveal is the narrative +- Slow creeping push-ins, dramatic scale reveals, silence before the hit +- Transitions: Domain Warp (dissolves reality itself before revealing the next scene) + +**GSAP signature:** `power4.in` for exits, `power3.out` for dramatic reveals. The pause before the hit matters. + +``` +Shadow Cut: Deep blacks #0a0a0a, cold greys, stark white + one accent (blood red or toxic green). +Sharp angular type, film noir aesthetic. Elements emerge from darkness. +Slow creeping push-ins. Domain Warp transitions. The reveal IS the story. +``` + +--- + +## Mood → Style Guide + +| If the content feels... | Use... | +| ---------------------------------- | --------------- | +| Data-driven, analytical, technical | Swiss Pulse | +| Premium, enterprise, luxury | Velvet Standard | +| Raw, punk, aggressive, rebellious | Deconstructed | +| Hype, loud, high-energy launch | Maximalist Type | +| AI, ML, speculative, futuristic | Data Drift | +| Human, warm, personal, wellness | Soft Signal | +| Cultural, fun, consumer, festive | Folk Frequency | +| Dark, dramatic, intense, cinematic | Shadow Cut | + +--- + +## Creating Custom Styles + +These 8 styles are examples — not constraints. Create your own by: + +1. **Name it** after a designer, art movement, or cultural reference +2. **Palette**: 2-3 colors max. Declare explicit hex values +3. **Typography**: One family, two weights. State the role of each +4. **Motion rules**: How fast? Snappy or fluid? Overshoot or precision? +5. **Transition**: Which shader matches the energy? +6. **What NOT to do**: 2-3 explicit anti-patterns for this style + +The pattern: **named style → palette → typography → motion rules → transition → avoids.** diff --git a/.agents/skills/website-to-hyperframes/SKILL.md b/.agents/skills/website-to-hyperframes/SKILL.md new file mode 100644 index 0000000..8d73fef --- /dev/null +++ b/.agents/skills/website-to-hyperframes/SKILL.md @@ -0,0 +1,121 @@ +--- +name: website-to-hyperframes +description: | + Capture a website and create a HyperFrames video from it. Use when: (1) a user provides a URL and wants a video, (2) someone says "capture this site", "turn this into a video", "make a promo from my site", (3) the user wants a social ad, product tour, or any video based on an existing website, (4) the user shares a link and asks for any kind of video content. Even if the user just pastes a URL — this is the skill to use. +--- + +# Website to HyperFrames + +Capture a website, then produce a professional video from it. + +Users say things like: + +- "Capture https://... and make me a 25-second product launch video" +- "Turn this website into a 15-second social ad for Instagram" +- "Create a 30-second product tour from https://..." + +The workflow has 7 steps. Each produces an artifact that gates the next. + +--- + +## Step 1: Capture & Understand + +**Read:** [references/step-1-capture.md](references/step-1-capture.md) + +Run the capture, read the extracted data, and build a working summary using the write-down-and-forget method. + +**Gate:** Print your site summary (name, top colors, fonts, key assets, one-sentence vibe). + +--- + +## Step 2: Write DESIGN.md + +**Read:** [references/step-2-design.md](references/step-2-design.md) + +Write a simple brand reference for the captured website. 6 sections, ~90 lines. This is a cheat sheet, not the creative plan — that comes in Step 4. + +**Gate:** `DESIGN.md` exists in the project directory. + +--- + +## Step 3: Write SCRIPT + +**Read:** [references/step-3-script.md](references/step-3-script.md) + +Write the narration script. The story backbone. Scene durations come from the narration, not from guessing. + +**Gate:** `SCRIPT.md` exists in the project directory. + +--- + +## Step 4: Write STORYBOARD + +**Read:** [references/step-4-storyboard.md](references/step-4-storyboard.md) + +Write per-beat creative direction: mood, camera, animations, transitions, assets, depth layers, SFX. This is the creative north star — the document the engineer follows to build each composition. + +**Gate:** `STORYBOARD.md` exists with beat-by-beat direction and an asset audit table. + +--- + +## Step 5: Generate VO + Map Timing + +**Read:** [references/step-5-vo.md](references/step-5-vo.md) + +Generate TTS audio, transcribe for word-level timestamps, and map timestamps to beats. Update STORYBOARD.md with real durations. + +**Gate:** `narration.wav` (or .mp3) + `transcript.json` exist. Beat timings in STORYBOARD.md updated. + +--- + +## Step 6: Build Compositions + +**Read:** The `/hyperframes` skill (invoke it — every rule matters) +**Read:** [references/step-6-build.md](references/step-6-build.md) + +Build each composition following the storyboard. After each one: self-review for layout, asset placement, and animation quality. + +**Gate:** Every composition has been self-reviewed. No overlapping elements, no misplaced assets, no static images without motion. + +--- + +## Step 7: Validate & Deliver + +**Read:** [references/step-7-validate.md](references/step-7-validate.md) + +Lint, validate, preview. Create a HANDOFF.md for multi-session continuity. + +**Gate:** `npx hyperframes lint` and `npx hyperframes validate` pass with zero errors. + +--- + +## Quick Reference + +### Video Types + +| Type | Duration | Beats | Narration | +| --------------------- | -------- | ----- | ---------------------- | +| Social ad (IG/TikTok) | 10-15s | 3-4 | Optional hook sentence | +| Product demo | 30-60s | 5-8 | Full narration | +| Feature announcement | 15-30s | 3-5 | Full narration | +| Brand reel | 20-45s | 4-6 | Optional, music focus | +| Launch teaser | 10-20s | 2-4 | Minimal, high energy | + +### Format + +- **Landscape**: 1920x1080 (default) +- **Portrait**: 1080x1920 (Instagram Stories, TikTok) +- **Square**: 1080x1080 (Instagram feed) + +### Reference Files + +| File | When to read | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [step-1-capture.md](references/step-1-capture.md) | Step 1 — reading captured data | +| [step-2-design.md](references/step-2-design.md) | Step 2 — writing DESIGN.md | +| [step-3-script.md](references/step-3-script.md) | Step 3 — writing the narration script | +| [step-4-storyboard.md](references/step-4-storyboard.md) | Step 4 — per-beat creative direction | +| [step-5-vo.md](references/step-5-vo.md) | Step 5 — TTS, transcription, timing | +| [step-6-build.md](references/step-6-build.md) | Step 6 — building compositions with self-review | +| [step-7-validate.md](references/step-7-validate.md) | Step 7 — lint, validate, preview, handoff | +| [techniques.md](references/techniques.md) | Steps 4 & 6 — 10 visual techniques with code patterns (SVG drawing, Canvas 2D, 3D, typography, Lottie, video, typing, variable fonts, MotionPath, transitions) | diff --git a/.agents/skills/website-to-hyperframes/references/step-1-capture.md b/.agents/skills/website-to-hyperframes/references/step-1-capture.md new file mode 100644 index 0000000..7703262 --- /dev/null +++ b/.agents/skills/website-to-hyperframes/references/step-1-capture.md @@ -0,0 +1,66 @@ +# Step 1: Capture & Understand + +## Run the capture + +```bash +npx hyperframes capture -o captures/ +``` + +No API keys required. The capture extracts design tokens, screenshots, fonts, and assets with DOM-context descriptions automatically. + +**Optional:** Set `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) in a `.env` file at the repo root for richer AI-powered image descriptions via Gemini 2.5 Flash vision. Free tier: 5 RPM; paid tier removes the bottleneck. + +Wait for it to complete. Print how many screenshots, assets, sections, and fonts were extracted. + +## Read and summarize + +Read each file below. After reading each one, **write a 1-2 sentence summary** of what you learned. These summaries are your working memory — the raw file content may be cleared from context later. + +### Must read (do not skip) + +1. **View the scroll screenshots** — viewport-sized captures covering the full page height (the number depends on the page length). Start with: + - `screenshots/scroll-000.png` — the hero section at full 1920x1080 resolution. This is the most important image. Describe: is the background light or dark? What's the dominant visual element? What colors jump out? + - Then scan through the rest to see the full page. Each screenshot overlaps the previous by ~30%. + + After viewing them, write 3-4 sentences describing the site's visual mood, layout patterns, color strategy, and overall feel. + +2. **`extracted/tokens.json`** — Note the top 5-7 colors (HEX), all font families, number of sections, and number of headings/CTAs. + +3. **`extracted/visible-text.txt`** — Note the site's headline, tagline, key selling points, and any notable statistics or social proof. + +4. **`extracted/asset-descriptions.md`** — One-line-per-file summary of all downloaded assets. Note which assets are most visually striking or useful for video (hero images, logos, product screenshots). + +### Read if they exist + +5. **`extracted/animations.json`** — Note if the site uses scroll-triggered animations, marquees, canvas/WebGL, or named CSS animations. + +6. **`extracted/lottie-manifest.json`** — View each preview image at `assets/lottie/previews/` to see what the animations look like. + +7. **`extracted/video-manifest.json`** — View each preview at `assets/videos/previews/` to see what each video shows. + +8. **`extracted/shaders.json`** — If present, this contains the actual GLSL shader code that powers the site's WebGL visual effects (gradient waves, particle systems, noise fields). Read the fragment shaders to extract: color values used in gradients, noise algorithms, blend functions. You can recreate similar effects in your compositions using Canvas 2D or by embedding the shader patterns with a `` + WebGL context. See the Canvas 2D and procedural art patterns in `techniques.md`. + +### On-demand (read when building scenes) + +9. **Individual images in `assets/`** — Use `asset-descriptions.md` as your index. View specific images when you need them for a beat. + +10. **`extracted/assets-catalog.json`** — Use to find remote URLs when you need an asset that wasn't downloaded. + +### For rich captures (30+ images) + +Launch a sub-agent to view all images and SVGs: + +> "Read every image in assets/ and every SVG in assets/svgs/. For each, write one line: filename — what it shows, dominant colors, approximate size. Return the complete catalog." + +Use the sub-agent's catalog as your asset reference for the rest of the workflow. + +## Gate + +Print your site summary before proceeding to Step 2: + +- **Site:** [name] +- **Colors:** [top 3-5 HEX values with roles] +- **Fonts:** [font families] +- **Sections:** [count] sections, [count] headings, [count] CTAs +- **Key assets:** [3-5 most useful assets for video] +- **Vibe:** [one sentence describing the visual identity] diff --git a/.agents/skills/website-to-hyperframes/references/step-2-design.md b/.agents/skills/website-to-hyperframes/references/step-2-design.md new file mode 100644 index 0000000..f4fc655 --- /dev/null +++ b/.agents/skills/website-to-hyperframes/references/step-2-design.md @@ -0,0 +1,128 @@ +# Step 2: Write DESIGN.md + +DESIGN.md is a **brand cheat sheet** for the captured website. It encodes the visual identity so you can reference exact colors, fonts, and patterns while writing the storyboard and compositions. + +DESIGN.md is NOT the creative plan. The STORYBOARD (Step 4) drives creative direction. DESIGN.md is a reference you consult, not a document you follow slavishly. + +## The 6 Sections + +### `## Overview` + +3-4 sentences. Describe the visual identity factually: layout patterns (bento grid, logo wall, hero section), color strategy, typography tone, overall feel. Be precise, not poetic. + +### `## Colors` + +5-10 key colors with HEX values from tokens.json and their roles: + +``` +- **Primary Surface**: `#020204` — deep black background +- **Primary Content**: `#FFFFFF` — high-purity white for text and borders +- **Accent Warm**: `#FB923C` — orange for CTAs and highlights +``` + +Include semantic colors if the site uses color to differentiate product areas. + +### `## Typography` + +Font families with weights, roles, and any distinctive usage: + +``` +- **Serif**: Cormorant Garamond (Italic). Major headings, brand identity. +- **Monospace**: Geist Mono. Subheaders, labels, terminal readouts. High tracking (0.1-0.3em), all-caps. +- **Sans-Serif**: Inter. Body copy, interface elements. Small sizes (9-14px). +``` + +Include sizing hierarchy if notable (hero: 64px, section: 32px, body: 16px). + +### `## Elevation` + +One paragraph on depth strategy: Does the site use borders, shadows, glassmorphism, or flat color shifts? Reference specific patterns (e.g., "1px borders at white/10 opacity" or "layered backdrop-blur with thin borders"). + +### `## Components` + +Name every notable UI component you see in the screenshot. Be specific: + +- "Cinematic Accordion" not "Cards" +- "Logo Marquee" not "Scrolling section" +- "Glass Cards with grain overlay" not "Content containers" + +For each, note the distinctive visual treatment (border-radius, spacing, hover behavior). + +### `## Do's and Don'ts` + +3-5 rules each, derived from what the site actually does and doesn't do: + +``` +### Do's +- Use thin subtle borders (white/10) to separate sections +- Keep imagery desaturated with dark gradients for text readability + +### Don'ts +- Do not use bright solid background colors — stay in "The Void" +- Do not use standard drop shadows — use radial glow or bloom effects +- Do not use sharp high-speed animations — all motion should be fluid +``` + +## Rules + +- Use **exact HEX values** from tokens.json. Do not approximate. +- Name components by what you see in the screenshot, not generic terms. +- Keep it under 100 lines. This is a cheat sheet, not a design system document. +- No "Style Prompt" section — the storyboard handles creative direction. +- No "Assets" section — `asset-descriptions.md` already covers this. +- No "Motion" section — the storyboard specifies motion per-beat. + +## Example + +This is a real DESIGN.md from a production capture (Soulscape 2026): + +```markdown +# Design System + +## Overview + +Soulscape 2026 is a cinematic, "high-signal" digital experience that positions itself as the vanguard of AI filmmaking. The visual personality is dark, technical, and premium, characterized by high-contrast "Flare" on "Void" (white on black) aesthetics. The layout is dense but organized, utilizing heavy horizontal layering and border-defined sections to evoke a wide-screen cinematic feel. Motion is a core tenet, with atmospheric grain overlays, shifting light leaks, and slow-moving marquees creating constant, breathing texture. + +## Colors + +- **Primary Surface**: `#020204` (Void) - Deep black for the entire background. +- **Primary Content**: `#FFFFFF` (Flare) - High-purity white for typography and primary borders. +- **Accent 1 (Warm)**: `#FB923C` - Orange for industry/executive tiers and primary CTAs. +- **Accent 2 (Cool)**: `#60A5FA` - Blue for creative voices and summit-focused components. +- **Subtle Overlays**: `rgba(255, 255, 255, 0.02)` to `0.08` for glass backgrounds. + +## Typography + +- **Serif**: Cormorant Garamond (Italic). Major headings and "Soul" brand identity. Classical cinematic contrast. +- **Monospace**: Geist Mono. Subheaders, labels, terminal readouts. High tracking (0.1-0.3em), all-caps. +- **Sans-Serif**: Inter. Body copy and interface elements. Small sizes (9-14px). + +## Elevation + +- **Glassmorphism**: Components use backdrop-filter blur(10px) with thin borders (1px solid rgba(255, 255, 255, 0.08)). +- **Layering**: Depth via fixed global grain-overlay and localized light-leak gradients rather than box-shadows. +- **Interaction**: Hover triggers subtle translateY(-5px) and increased border opacity. + +## Components + +- **Cinematic Accordion**: Expanding horizontal/vertical card system where panels expand from compressed state to reveal full-bleed imagery and large serif typography. +- **HUD Explorer**: Floating mobile navigation trigger styled as a "Lens" with pulsing glow and terminal readouts. +- **Slow Marquees**: Continuous horizontal tickers for partner logos and veteran listings. +- **Glass Cards**: Content containers with subtle gradients, rounded corners (2.5rem), and high-contrast iconography. +- **Grain & Flicker**: Global CSS noise filters and holographic flicker animations on UI labels. + +## Do's and Don'ts + +### Do's + +- Use thin subtle borders (white/10) to separate sections rather than solid color changes. +- Maintain high letter-spacing on all Geist Mono labels. +- Use serif italics for emotional or visionary statements. +- Keep imagery desaturated or stylized with dark gradients for readability. + +### Don'ts + +- Do not use bright solid background colors — the page must remain in "The Void." +- Do not use standard drop shadows — use radial glow or bloom effects instead. +- Do not use sharp high-speed animations — all motion should be fluid and breathing. +``` diff --git a/.agents/skills/website-to-hyperframes/references/step-3-script.md b/.agents/skills/website-to-hyperframes/references/step-3-script.md new file mode 100644 index 0000000..2eca87a --- /dev/null +++ b/.agents/skills/website-to-hyperframes/references/step-3-script.md @@ -0,0 +1,96 @@ +# Step 3: Write the Narration Script + +**Before writing, re-read DESIGN.md** — specifically the Overview and Components sections. The script should reference real product features, real stats, and real components that the website highlights. Use exact numbers from `extracted/visible-text.txt`. + +The script is the backbone. Everything downstream — scene durations, animation timing, beat pacing — comes from the narration. Write it before the storyboard. + +Save as `SCRIPT.md` in the project directory. + +## Pacing + +- **2.5 words per second** is natural speaking pace +- 15s = ~37 words. 30s = ~75 words. 60s = ~150 words +- Leave room for pauses. Silence between sentences is a feature, not dead air +- The script should feel SHORTER than the video — visual breathing room matters + +## Tone + +Write like a person, not a brochure: + +- Use contractions: "it's", "you'll", "that's", "we've" +- Vary sentence length — short punchy phrases mixed with longer flowing ones +- Read it out loud. If it sounds robotic, rewrite it +- Avoid jargon unless the audience expects it + +## Number Pronunciation + +Write what you want the voice to say. TTS reads literally. + +| On the website | Write in script as | +| -------------- | --------------------------------- | +| 135+ | more than one hundred thirty five | +| $1.9T | nearly two trillion dollars | +| 99.999% | ninety nine point nine percent | +| 200M+ | over two hundred million | +| 10x | ten times | +| API | A P I | +| stripe.com | stripe dot com | + +The visual can show the exact figure while the voice rounds it. + +## Structure + +For product videos from a website capture: + +1. **Hook** — what's surprising or impressive about this product? A bold claim, a provocative question, a contrast, or a striking number. This is the opening line. **Vary the hook type** — don't default to a stat every time. +2. **Story** — what does the product do? Who uses it? Keep it concrete. +3. **Proof** — stats, customer names, social proof. Real numbers from the website. +4. **CTA** — what should the viewer do? "Start building at stripe dot com." + +Not every video needs all four. A 15-second social ad might be Hook + Proof + CTA. A 60-second product tour uses all four with more Story. + +## The Opening Line + +The most important sentence in the video. It must create tension, curiosity, or surprise in the first 3 seconds. + +Patterns that work: + +- **A bold claim**: "The financial infrastructure that powers the internet economy." +- **A question that provokes**: "What if your database could think?" +- **A contrast**: "Your AI agent already knows how to make videos. It just needs the right format." +- **A number that shocks**: "Nearly two trillion dollars." (Use sparingly — not every video should open with a stat.) + +If the opening is generic ("Welcome to Stripe" / "Introducing our product"), start over. + +## Example + +From a 62-second product launch video (team reference): + +``` +Your AI agent already knows how to make videos. +It just needs the right format. + +This is Hyperframes. An open source framework. HTML in, video out. + +A div is a keyframe. Data attributes are your timeline. +CSS is your look. G-Sap is your animation engine. + +Anything a browser can render can be a frame in your video. + +CSS animations. G-Sap. Lottie. Shaders. Three.js. + +Drop in music, sound effects, footage — it all composes together. + +No new framework for the agent to learn. +Just HTML. + +The agent writes it. The renderer captures every frame as MP4. +It's deterministic. Identical outputs, every time. + +Give your agent the CLI. Tell it what to make. +Watch it build. + +Hyperframes. Go make something. +``` + +Note: ~140 words for 62 seconds — that's 2.3 words/sec, leaving room for pauses and visual breathing. diff --git a/.agents/skills/website-to-hyperframes/references/step-4-storyboard.md b/.agents/skills/website-to-hyperframes/references/step-4-storyboard.md new file mode 100644 index 0000000..0579c94 --- /dev/null +++ b/.agents/skills/website-to-hyperframes/references/step-4-storyboard.md @@ -0,0 +1,222 @@ +# Step 4: Write the Storyboard + +**Before writing anything, fully re-read these files:** + +- **DESIGN.md** — your color palette, font rules, components, Do's/Don'ts. Every creative decision must be grounded in this brand identity. If it says "white backgrounds with purple accent" — plan light scenes, not dark moody ones. +- **`extracted/asset-descriptions.md`** — read EVERY line. This is your menu of available visuals. Each line describes what the image actually shows (e.g., "translucent ribbons in orange, pink, and purple on white background" or "a high-speed train under a dark starry sky"). Use these descriptions to decide which assets belong in which beat. Assets you don't understand from the description — view them directly before assigning. +- **[techniques.md](techniques.md)** — 10 visual techniques (SVG path drawing, Canvas 2D art, CSS 3D, per-word typography, Lottie, video compositing, typing effect, variable fonts, MotionPath, velocity transitions). Pick 2-3 per beat and specify them in the storyboard. + +The storyboard is the creative north star. It tells the engineer exactly what to build for each beat — mood, camera, animations, transitions, assets, sound. Write it as if you're briefing a motion designer who's never seen the website. + +Save as `STORYBOARD.md` in the project directory. + +--- + +## Global Direction + +Every STORYBOARD.md starts with global settings: + +```markdown +**Format:** 1920×1080 +**Audio:** [TTS provider] voiceover + underscore + SFX +**VO direction:** [voice character — e.g., "mid-age male, calm confident delivery, +Apple keynote register — economy of words, silence between sentences is a feature"] +**Style basis:** DESIGN.md (brand colors, fonts, components from the captured site) +``` + +**Global guardrails** (adapt to the brand): + +- Push color presence. Muted is fine, flat is not. Every beat should have at least one color that pulls your eye. +- Motion should be visible and intentional. Err toward more movement than feels safe — subtle reads as static at 30fps. +- Use as many captured assets as the creative vision allows. Scatter framework icons around a dashboard. Layer enterprise photos behind stats. Use product screenshots as floating cards. The assets exist — use them generously. +- Aim for 8-10 visual elements per beat, not 2-3. A great beat has: background texture, midground content, foreground accents, floating decorative elements, animated icons, SVG path drawings, particle effects, typographic details. It should feel DENSE and alive. +- Use at least 2-3 different techniques from techniques.md per beat — not across the whole video, per beat. Don't default to basic fade/scale/opacity — mix in SVG path drawing, CSS 3D transforms, typing effects, counter animations, canvas procedural art. Each beat should feel like its own visual world. + +**Underscore/music direction** (if applicable): + +- Describe the mood, reference artists, when it swells or drops +- Example: "Minimal electronic. Warm sustained pad already playing when the video starts. Sits underneath everything, never competing with VO. Swells gently during the flex section, drops to near-nothing for the comparison, resolves on a final chord." + +--- + +## Asset Audit + +Before writing any beats, audit every captured asset. Print this table: + +| Asset | Type | Assign to Beat | Role | +| ------------------------------ | ---------- | -------------- | ------------------------------------- | +| wave-fallback-desktop.png | Hero image | Beat 1 | Full-bleed animated background | +| enterprise-accordion-hertz.png | Photo | Beat 3 | Enterprise credibility, Ken Burns pan | +| stripe-logo.svg | SVG | Beat 1, Beat 5 | Brand mark opener + closer | +| datavizstatic3x.png | Data viz | Beat 3 | Supporting visual behind stats | +| icon-3.svg | Icon | SKIP | Decorative, too small | + +**Minimum utilization:** + +- At least 50% of product screenshots and hero images must appear +- Brand logo appears in the first AND last beat +- The site's signature visual (gradient wave, hero illustration, key product UI) must appear — it's the most recognizable brand element +- Maximum 2 consecutive text-only beats. The 3rd must contain a visual asset +- Opening beat must contain a visual asset, not text-only + +--- + +## Per-Beat Direction + +Each beat is a WORLD, not a layout. Before writing CSS specs and GSAP instructions, describe what the viewer EXPERIENCES. The difference between a great storyboard and a mediocre one: + +**Mediocre:** "Dark navy background. '$1.9T' in white, 280px. Logo top-left. Wave image bottom-right." +**Great:** "Camera is already mid-flight over a vast dark canvas. The gradient wave sweeps across the frame like aurora borealis — alive, shifting. '$1.9T' SLAMS into existence with such force the wave ripples in response. This isn't a slide — it's a moment." + +The first describes pixels. The second describes an experience. Write the second, then figure out the pixels. + +Each beat should have: + +### Concept + +The big idea for this beat in 2-3 sentences. What visual WORLD are we in? What metaphor drives it? What should the viewer FEEL? This is the most important part — everything else flows from it. + +### VO cue + +Which narration line plays over this beat. + +### Visual description + +What the viewer sees — described cinematically, not as CSS specs. Use camera language (pan, zoom, drift, settle). Describe at least 5 visual elements, not just text + background. Think in layers — what's moving in the foreground, midground, background simultaneously? + +### Mood direction + +Cultural and design references, not hex codes: + +- "Geometric, rhythmic, precise. Think Josef Albers or Bauhaus color studies." +- "Warm workspace. Nice notebook energy, not technical blueprint." +- "Cinematic title sequence. The kind of opening where you lean forward." + +### Assets + +Which captured files to use, referenced by filename: + +- "Background: `assets/wave-fallback-desktop.png` — full-bleed, slow zoom 1→1.04 over beat duration" +- "Logo: `assets/svgs/stripe-logo.svg` — centered, fades in at 0.5s" +- "Enterprise photo: `assets/enterprise-accordion-hertz.png` — Ken Burns pan, 70% opacity overlay" + +### Animation choreography + +Specific motion verbs per element — not "it animates in" but HOW: + +| Energy | Verbs | Example | +| ------------- | --------------------------------------------- | ------------------------------------- | +| High impact | SLAMS, CRASHES, PUNCHES, STAMPS, SHATTERS | "$1.9T" SLAMS in from left at -5° | +| Medium energy | CASCADE, SLIDES, DROPS, FILLS, DRAWS | Three cards CASCADE in staggered 0.3s | +| Low energy | types on, FLOATS, morphs, COUNTS UP, fades in | Counter COUNTS UP from 0 to 135K | + +Every element gets a verb. If you can't name the verb, the element is not yet designed. + +### Transition + +How this beat hands off to the next. Specify the type and parameters. + +**CSS transitions** (choose from `skills/hyperframes/references/transitions/catalog.md`): + +- Velocity-matched upward: exit `y:-150, blur:30px, 0.33s power2.in` → entry `y:150→0, blur:30px→0, 1.0s power2.out` +- Whip pan: exit `x:-400, blur:24px, 0.3s power3.in` → entry `x:400→0, blur:24px→0, 0.3s power3.out` +- Blur through: exit `blur:20px, 0.3s` → entry `blur:20px→0, 0.25s power3.out` +- Zoom through: exit `scale:1→1.2, blur:20px, 0.2s power3.in` → entry `scale:0.75→1, blur:20px→0, 0.5s expo.out` +- Hard cut / smash cut (for rapid-fire sequences) + +**Shader transitions** (choose from `skills/hyperframes/references/transitions/shader-transitions.md`): + +- Cross-Warp Morph (organic, versatile) — 0.5-0.8s, power2.inOut +- Cinematic Zoom (professional momentum) — 0.4-0.6s, power2.inOut +- Gravitational Lens (otherworldly) — 0.6-1.0s, power2.inOut +- Glitch (aggressive, high energy) — 0.3-0.5s +- See `skills/hyperframes/references/transitions/shader-setup.md` for the full WebGL boilerplate + +**How velocity-matched CSS transitions work:** +Exit the outgoing beat with an accelerating ease (power2.in or power3.in) plus a blur ramp. Enter the incoming beat with a decelerating ease (power2.out or power3.out) plus blur clear. The fastest point of both easing curves meets at the cut — the viewer perceives continuous camera motion, not two discrete animations. Match exit velocity to entry velocity within ~5% tolerance. + +### Depth layers + +What's in foreground, midground, and background. Every beat should have at least 2 layers: + +- "BG: dark navy fill + subtle radial glow. MG: stat cards with drop shadow. FG: brand logo bottom-right." + +### SFX cues + +What sounds at what moment: + +- "On the capture pulse — a soft, warm analog shutter click." +- "Left side carries a faint low drone. On fold: drone cuts. Silence. Then a single clean chime." + +--- + +## Production Architecture + +Include this file tree at the bottom of the storyboard: + +``` +project/ +├── index.html root — VO + underscore + beat orchestration +├── DESIGN.md brand reference (from Step 2) +├── SCRIPT.md narration text (from Step 3) +├── STORYBOARD.md THIS FILE — creative north star +├── transcript.json word-level timestamps (from Step 5) +├── narration.wav TTS audio (from Step 5) +├── captures// captured website data +└── compositions/ + ├── beat-1-hook.html + ├── beat-2-features.html + ├── ... + └── captions.html +``` + +--- + +## Example: Beat-by-Beat Format + +Here are three beats from a production storyboard showing the level of detail expected. + +### BEAT 1 — COLD OPEN (0:00–0:05) + +**VO:** "Your AI agent already knows how to make videos." + +**Concept:** We're already in motion when the video starts. No title card, no fade from black. We're mid-flight over an infinite creative workspace — dozens of living compositions scattered below us like a city seen from a drone. Each one is alive, running a different animation. The message is clear before any words: this tool makes videos. Lots of them. + +**Visual:** Slow smooth diagonal drift over a vast canvas (3600×2200px plane). Scattered across it: 25 composition cards at organic angles (±5-15° rotation), soft shadows, thin borders. Each card contains a DIFFERENT running animation — kinetic type, gradient morph, data viz, particle system, logo assembly, SVG drawing, shader noise, 3D rotating object. Depth-of-field: close cards slightly blurred, focal sweet-spot in mid-distance, far cards smaller and desaturated. + +**Camera:** Diagonal drift top-left to bottom-right, slight 2-3° rotation over 5s. power1.inOut ease. Zoom accelerates in final second as we approach one specific card. + +**Assets:** Product screenshots and logo on cards. Each card is a mini-composition with its own animation. + +**SFX:** Ambient warmth pad already playing. Faint textured hum — overhearing creative activity from a distance. + +--- + +### BEAT 5 — THE THESIS (0:20–0:24) + +**VO:** "Anything a browser can render can be a frame in your video." + +**Mood:** Big statement. This sentence gets its own canvas. Clean, spacious, typographic. + +**Visual:** Words appear as staggered kinetic typography. "Anything a browser can render" — distinctive serif, gentle fade + rise (y: 24px → 0, opacity 0 → 1, 0.4s, power2.out). Held beat — one second of stillness. "can be a frame in your video." appears below. As the final word lands, the entire text pulses once — a brief warm flash, subtle scale bump to 101%. + +**Transition OUT:** Whip pan left — x:-400, blur:24px, opacity:0.4, 0.3s power3.in + +**SFX:** Silence under the first line. On the capture pulse — a soft analog shutter click. + +--- + +### BEAT 7 — THE CONTRAST (0:38–0:44) + +**VO:** "No new framework for the agent to learn. Just HTML." + +**Mood:** Clean comparison. Light base. Two worlds side by side. + +**Visual:** Left half: dense code, small, compressed, overwhelming. Scrolls slowly upward. Slightly desaturated. Right half: spacious HTML, syntax-highlighted, generous line spacing, inviting. On "Just HTML." — the left side folds inward along its center line, like a book closing. The right side expands to fill the frame. Warm glow rises behind it. + +**Transition IN:** Zoom through — scale 0.75→1, blur 20px→0, 0.5s expo.out +**Transition OUT:** Velocity-matched upward — y:-150, blur:30px, 0.33s power2.in + +**Assets:** Real framework code on the left (actual content, not lorem ipsum). Real HyperFrames HTML on the right. + +**SFX:** Left side carries a faint low drone. On fold: drone cuts. Silence. Then a single clean chime as the right side expands. diff --git a/.agents/skills/website-to-hyperframes/references/step-5-vo.md b/.agents/skills/website-to-hyperframes/references/step-5-vo.md new file mode 100644 index 0000000..be656c6 --- /dev/null +++ b/.agents/skills/website-to-hyperframes/references/step-5-vo.md @@ -0,0 +1,40 @@ +# Step 5: Generate VO + Map Timing + +## Audition voices + +Never use the first voice you find. Audition 2-3 voices with the first sentence of SCRIPT.md: + +- **Kokoro** (try first — free, no API key) — `npx hyperframes tts SCRIPT.md --voice af_nova --output narration.wav`. Runs locally on CPU. Requires Python 3.10+ (macOS system Python 3.9 won't work — if it fails with an onnxruntime error, move to the next option). +- **ElevenLabs** (best voice quality, widest selection) — `mcp__elevenlabs__search_voices` to browse, `mcp__elevenlabs__text_to_speech` to generate. Does not return timestamps — transcribe separately after. +- **HeyGen TTS** (returns word timestamps automatically — saves a transcribe step) — `mcp__claude_ai_HeyGen__text_to_speech`. Use when you want timestamps without a separate transcription pass. + +Pick the voice that sounds most natural and conversational. Listen for pacing — does it breathe between sentences? Does it sound like a person or a robot? + +## Generate full narration + +Generate the full script as `narration.wav` (or `.mp3`) in the project directory. + +## Transcribe for word-level timestamps + +```bash +npx hyperframes transcribe narration.wav +``` + +Produces `transcript.json` with `[{ text, start, end }]` for every word. These timestamps are the source of truth for all beat durations. + +## Map timestamps to beats + +Go through STORYBOARD.md beat by beat. For each beat: + +1. Find the first word of that beat's VO cue in `transcript.json` +2. Find the last word of that beat's VO cue +3. Set `beat.start = firstWord.start`, `beat.end = lastWord.end` +4. Add 0.3-0.5s padding at the end for visual breathing room + +Update STORYBOARD.md with real durations. Replace estimated times (e.g., "0:00-0:05") with actual timestamps (e.g., "0.00-3.21s"). + +Beat boundaries land on word onsets — hard cuts to the VO. + +## Update index.html + +Update each scene slot's `data-start` and `data-duration` to match the real beat timings from the transcript. Also update the total composition duration and audio element duration. diff --git a/.agents/skills/website-to-hyperframes/references/step-6-build.md b/.agents/skills/website-to-hyperframes/references/step-6-build.md new file mode 100644 index 0000000..be8a2d4 --- /dev/null +++ b/.agents/skills/website-to-hyperframes/references/step-6-build.md @@ -0,0 +1,162 @@ +# Step 6: Build Compositions + +**Before building, fully re-read these files:** + +- **DESIGN.md** — your color palette, fonts, components, and Do's/Don'ts. Every composition must use EXACT hex colors and font families from this file. If it says "white backgrounds" — use white, not dark. +- **STORYBOARD.md** — the beat-by-beat plan you're executing. Each beat specifies assets, animations, transitions, and which techniques to use. +- **`extracted/asset-descriptions.md`** — when the storyboard assigns an asset to a beat, re-read the description to understand what it shows and how to position/style it correctly. +- **[techniques.md](techniques.md)** — code patterns for the 10 visual techniques. When the storyboard says "SVG path drawing" or "per-word kinetic typography" — read the code pattern from this file and adapt it. +- **transcript.json** — word-level timestamps that drive scene durations. + +**Split the work: spawn a sub-agent for each beat.** By this step your context is full of captured data, DESIGN.md, SCRIPT, STORYBOARD, and transcript. Building compositions on top of all that means the detailed rules below compete with thousands of tokens of prior work. Each sub-agent gets a fresh context focused on one beat — dramatically better output. + +**How to dispatch each sub-agent:** + +Pass file PATHS, not file contents. The #1 failure mode is reading an asset file and pasting its SVG/image data into the sub-agent prompt. The sub-agent then uses inline content instead of referencing the file on disk. Same with fonts — pass the local woff2 path, don't substitute Google Fonts. + +``` +Build the composition for beat 1. Save to compositions/beat-1-hook.html. + +STORYBOARD for this beat: +[paste the beat section from STORYBOARD.md] + +ASSETS — reference by path, do NOT read/inline the file contents: +- Logo: (top-left, 40x40px) +- Hero image: (full-bleed background) +- Noise texture: ../assets/noise.png (full-frame overlay, 3% opacity) + +FONTS — use @font-face with the captured font files, NOT Google Fonts: +@font-face { font-family: 'BrandFont'; src: url('../assets/fonts/BrandFont-Regular.woff2'); } + +Read DESIGN.md for exact colors and Do's/Don'ts. +Read techniques.md for animation code patterns. +Invoke /hyperframes for composition structure rules. +``` + +After each sub-agent finishes, verify the composition references `../assets/` — if it used inline SVGs or Google Fonts instead of the captured files, fix it before moving on. + +Invoke the `/hyperframes` skill first — it has the rules for data attributes, timeline contracts, deterministic rendering, and layout. Everything below supplements those rules, not replaces them. + +--- + +## Per-Composition Process + +For each beat in the storyboard: + +### 1. Read the beat's storyboard section + +Know the mood, visual description, assets, animation choreography, transition, and SFX before writing any HTML. + +### 2. Build the static end-state first + +Position every element where it should be at its **most visible moment** — the frame where everything is fully entered and correctly placed. Write this as static HTML+CSS. No GSAP yet. + +This is the "Layout Before Animation" principle from the compose skill. The CSS position is the ground truth. Animations describe the journey to and from it. + +### 3. Verify the static layout + +Look at it. Check: + +- Are elements where the storyboard says they should be? +- Are depth layers present (foreground / midground / background)? +- Do any elements overlap unintentionally? +- Are assets sized correctly? (hero images should fill 50-70% of frame, not sit at 100x100px) + +### 4. Add entrance animations + +Use `gsap.from()` — animate FROM offscreen/invisible TO the CSS position. The CSS position is where the element ends up. + +### 5. Add mid-scene activity + +Every visible element must have continuous motion. A still image on a still background is a JPEG with a progress bar. + +| Element type | Mid-scene activity | +| ---------------------- | ------------------------------------------------ | +| Image / screenshot | Slow zoom (scale 1→1.03), slow pan, or Ken Burns | +| Stat / number | Counter animates from 0 to target | +| Logo grid | Subtle shimmer sweep, or gentle scale pulse | +| Any persistent element | Subtle float (y ±4-6px, sine.inOut, yoyo) | + +### 6. Add exit / transition + +Check the storyboard's transition specification for this beat: + +- **CSS transition**: implement the exit animation (e.g., `y:-150, blur:30px, 0.33s power2.in`). The next composition handles its own entry. +- **Shader transition**: no exit animation needed — the shader handles the blend. Read `skills/hyperframes/references/transitions/shader-setup.md` for the full WebGL boilerplate and `skills/hyperframes/references/transitions/shader-transitions.md` for the fragment shader. Copy the FULL boilerplate — a simplified version produces black screens. +- **Hard cut**: no exit animation. The scene simply ends. + +For all CSS transition types and their GSAP implementations, read `skills/hyperframes/references/transitions/catalog.md`. + +### 7. Asset cross-reference + +Before self-review, verify you actually used the assets you planned to: + +1. Open STORYBOARD.md and find this beat's asset assignments +2. List every asset that was assigned to this beat +3. Search the composition HTML for each filename (e.g., grep for "wave-fallback-desktop") +4. If any assigned asset is missing from the HTML, add it now +5. Check for the inline anti-pattern: if the HTML contains `` +6. Check fonts: if the HTML uses `fonts.googleapis.com` but there are captured fonts in `assets/fonts/`, replace with `@font-face` pointing to the local files + +This step catches the two most common failures: compositions ending up text-only, and assets being inlined instead of file-referenced. + +### 8. Self-review + +After building the composition, check WITH ACTUAL CODE: + +- [ ] Asset cross-reference passed (step 7 above — every assigned asset is in the HTML) +- [ ] Elements are where the storyboard says they should be (no misplacement) +- [ ] No overlapping text (text covering text is always ugly) +- [ ] Depth layers present (2+ layers minimum) +- [ ] Every visible element has mid-scene activity (not just entrance + exit) +- [ ] Font sizes above minimum (20px body text, 16px labels — sub-14px is unreadable after encoding) +- [ ] No full-screen dark linear gradients (H.264 creates visible banding — use solid + localized radial glows) +- [ ] Timeline registered: `window.__timelines["comp-id"] = tl` +- [ ] Colors match DESIGN.md exactly (paste the HEX value, don't approximate) + +**If `skills/hyperframes-animation-map/` is installed**, run it: + +```bash +node skills/hyperframes-animation-map/scripts/animation-map.mjs +``` + +Read the summaries. Fix every flag: offscreen, collision, invisible, pacing issues. + +### 9. Move to the next composition + +--- + +## Asset Presentation + +Never embed a raw flat image. Every image must have motion treatment: + +- **Perspective tilt**: use `gsap.set(el, { transformPerspective: 1200, rotationY: -8 })` + `box-shadow` — creates depth. Do NOT use CSS `transform: perspective(...)` as GSAP will overwrite it. +- **Slow zoom (Ken Burns)**: GSAP `scale: 1` → `1.04` over beat duration — makes photos cinematic +- **Device frame**: Wrap in a laptop/phone shape using CSS `border-radius` and `box-shadow` +- **Floating UI**: Extract a key element and animate it at a different z-depth for parallax +- **Scroll reveal**: Clip the image to a viewport window and animate `y` position + +--- + +## Audio Wiring + +In the root `index.html`: + +- **Narration**: `` but the snapshot shows no wave — the image isn't loading, the path is wrong, or it's hidden behind another element. +- If a snapshot shows nothing at a timestamp, try a slightly different time (1-2 seconds later). Compositions may still be in entrance animations. +- The snapshot command is fast — run it multiple times at different timestamps if needed. + +If any frame has issues, go back to Step 6 and fix that composition before proceeding. + +## Preview + +```bash +npx hyperframes preview +``` + +Open the studio in a browser. Scrub through every beat. + +## Create HANDOFF.md + +Write a `HANDOFF.md` for multi-session continuity: + +```markdown +# Handoff — [Project Name] + +**Date:** [today] +**Preview:** `npx hyperframes preview` + +## What's Built + +| Beat | File | Dur | Status | Notes | +| ---- | -------------------- | ---- | ------ | ----- | +| 1 | beat-1-hook.html | 5.2s | Built | ... | +| 2 | beat-2-features.html | 6.8s | Built | ... | + +## Audio + +| Asset | Status | Notes | +| --------------- | ------ | ------------------------------------ | +| narration.wav | Done | [provider], [voice name], [duration] | +| transcript.json | Done | [word count] words, [duration] | + +## What Needs Work + +- [any known issues, polish requests, missing SFX] + +## Commands + +npx hyperframes preview +npx hyperframes lint +npx hyperframes validate +npx hyperframes snapshot --at +npx hyperframes render --output renders/final.mp4 +``` diff --git a/.agents/skills/website-to-hyperframes/references/techniques.md b/.agents/skills/website-to-hyperframes/references/techniques.md new file mode 100644 index 0000000..f380dff --- /dev/null +++ b/.agents/skills/website-to-hyperframes/references/techniques.md @@ -0,0 +1,341 @@ +# Visual Techniques Reference + +10 proven techniques from production HyperFrames videos. Use these in your storyboard and compositions to create visually rich, professional output. Each technique includes a minimal code pattern you can adapt. + +These are NOT advanced — they're standard motion design patterns that every composition should use at least 2-3 of. + +--- + +## 1. SVG Path Drawing + +A path draws itself in real-time, like someone tracing with a pen. Use for revealing diagrams, arrows, connector lines, or brand marks. + +```html + + + + + +``` + +Use `path.getTotalLength()` to calculate the dasharray value dynamically. + +--- + +## 2. Canvas 2D Procedural Art + +Animated noise, particle fields, data visualizations — anything that evolves frame-by-frame. Drive it with a GSAP proxy. + +```html + + +``` + +The `hash()` function is deterministic — same frame renders identically every time. + +--- + +## 3. CSS 3D Transforms + +Perspective rotations create depth. Use for product showcases, card flips, architectural reveals. + +```html +
+
+
Product
+
Details
+
+
+ +``` + +Always set `perspective` on the parent, `transform-style: preserve-3d` on the animated element. + +--- + +## 4. Per-Word Kinetic Typography + +Words appear one-by-one, synced to transcript.json timestamps. The core technique for narration-driven videos. + +```html +
+ Anything + a + browser + can + render +
+ + +``` + +The slide distance DECAYS per word (80→12px) — mimics a camera settling. + +--- + +## 5. Lottie Animation + +Vector animations that play inside a composition. Use for logos, character animations, icons. + +```html + + + + +``` + +Or use lottie-web for more control: + +```javascript +var anim = lottie.loadAnimation({ + container: document.getElementById("anim"), + renderer: "svg", + loop: false, + autoplay: false, + path: "../assets/lottie/animation-0.json", +}); +``` + +--- + +## 6. Video Compositing + +Embed real video footage inside compositions. Videos must be `muted` with `playsinline`. + +```html +
+ +
+ +``` + +The HyperFrames runtime handles video seeking and playback. + +--- + +## 7. Character-by-Character Typing + +Terminal typing effect using `tl.call()` to update text content character by character. + +```html +
+ + + +
+ +``` + +Use `ease: "steps(1)"` for cursor blink — creates discrete on/off. + +--- + +## 8. Variable Font Axis Animation + +Animate font-variation-settings to reshape glyphs in real-time. Works with variable fonts that have axes like optical size (opsz), weight (wght), softness (SOFT). + +```html + + +``` + +The glyph subtly reshapes as axes animate — optical size adjusts detail, weight changes thickness. + +--- + +## 9. GSAP MotionPathPlugin + +Animate an element along an arbitrary SVG path. Use for sliders following curves, particles along trajectories, guided reveals. + +```html + +
+ +``` + +--- + +## 10. Velocity-Matched Transitions + +Exit one beat and enter the next with matched velocities — creates perceived continuous motion. + +```javascript +// EXIT (in outgoing composition): accelerating with blur +tl.to( + ".content", + { + y: -150, + filter: "blur(30px)", + opacity: 0, + duration: 0.33, + ease: "power2.in", // accelerates + }, + beatDuration - 0.33, +); + +// ENTRY (in incoming composition): decelerating from blur +gsap.set(".content", { y: 150, filter: "blur(30px)" }); +tl.to( + ".content", + { + y: 0, + filter: "blur(0px)", + duration: 1.0, + ease: "power2.out", // decelerates + }, + 0, +); +``` + +The fastest point of both curves meets at the cut — the viewer perceives smooth camera motion. Match ease families: `.in` for exits, `.out` for entries. + +--- + +## When to Use What + +| Video energy | Techniques to combine | +| ------------------------------ | --------------------------------------------------------------- | +| High impact (launches, promos) | Per-word typography + velocity transitions + counter animations | +| Cinematic (tours, stories) | SVG path drawing + video compositing + 3D transforms | +| Technical (dev tools, APIs) | Character typing + Canvas 2D procedural + MotionPath | +| Premium (luxury, enterprise) | Variable font animation + Lottie + slow velocity transitions | +| Data-driven (stats, metrics) | Canvas 2D procedural + counter animations + SVG path drawing | diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 75e461d..add7be4 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -114,6 +114,22 @@ The agent must ask the user before changing any major production choice, includi Minor prompt refinements inside an already approved provider/model path do not require separate approval unless they materially change the creative direction. +### Present Both Composition Runtimes (HARD RULE) + +When both Remotion and HyperFrames are available on the machine (check `video_compose.get_info()["render_engines"]`), the agent **MUST present both options to the user** before locking `render_runtime` at the proposal stage. The agent MAY recommend one with rationale — but silently picking a "default" is forbidden even when the pipeline manifest or a director skill suggests one. + +The presentation MUST include, for each runtime: + +1. A one-sentence plain-language description of what it is best at for **this specific brief**. +2. A one-sentence honest tradeoff (why it might not be the right pick here). +3. The agent's recommendation and the reason, tied to the brief's delivery_promise and visual approach. + +Then wait for explicit user approval before advancing. Record the full shortlist — BOTH runtimes plus any "ffmpeg" option that applies — as `options_considered` in the `render_runtime_selection` decision logged in `decision_log`. A decision log entry with only one runtime considered when both were available is a CRITICAL reviewer finding. + +Exception: if only one runtime is available on the machine, the agent proceeds with it but MUST say so explicitly ("HyperFrames isn't installed on this machine; I'm proceeding with Remotion. Install HyperFrames if you want the alternative."). The `render_runtime_selection` decision still records the unavailable option as `rejected_because: "runtime not available on this machine"`. + +This rule applies to every pipeline that invokes `video_compose` — not just Wave 1. A pipeline's director skill may recommend a runtime, but that recommendation is input to the conversation with the user, not a decision. + ### Escalate Blockers Explicitly When a blocker occurs, the agent must surface it immediately using this structure: @@ -223,9 +239,31 @@ If the folder has tracks, the proposal and asset stages should present them as o ## Mandatory Preflight -Do this before any creative work: +Do this before any creative work. **Use `provider_menu_summary()` first — it's the human-ready rollup.** The raw `support_envelope()` dump is a firehose (megabytes of JSON on a well-configured machine); pasting it into chat will bury the user. ```bash +python -c " +from tools.tool_registry import registry +import json +registry.discover() +print(json.dumps(registry.provider_menu_summary(), indent=2)) +" +``` + +The summary returns four fields the agent should translate into plain language: + +- `composition_runtimes` — booleans for `ffmpeg`, `remotion`, `hyperframes`. This is the source of truth for the "Present Both Composition Runtimes (HARD RULE)" check. +- `capabilities[]` — one entry per capability family with `configured / total` counts and provider lists. Ready-made for the "N of M configured" menu. +- `setup_offers[]` — unavailable tools whose install is a 1-minute env-var fix. Lead with these when offering upgrades. +- `runtime_warnings[]` — specific signals like "hyperframes: npm package not resolvable". Surface these to the user verbatim — they're the kind of silent-failure bugs that break the governance contract. + +Then, for deeper inspection (only when the summary isn't enough): + +```bash +# Full menu — grouped available/unavailable per capability. +python -c "from tools.tool_registry import registry; import json; registry.discover(); print(json.dumps(registry.provider_menu(), indent=2))" + +# Raw envelope — every tool's full contract. Slow/firehose; use for debugging only. python -c "from tools.tool_registry import registry; import json; registry.discover(); print(json.dumps(registry.support_envelope(), indent=2))" ``` @@ -239,18 +277,7 @@ Then: ### Provider Menu (Mandatory at Preflight) -After running `support_envelope()`, run the **provider menu** to see the full picture: - -```bash -python -c " -from tools.tool_registry import registry -import json -registry.discover() -print(json.dumps(registry.provider_menu(), indent=2)) -" -``` - -This returns every capability grouped by status — how many providers the user has configured vs. how many exist. **Present this to the user as a capability menu**, not as a flat tool list. +Already fetched via `provider_menu_summary()` above. Read that output and **present it to the user as a capability menu**, not as a flat tool list. Use `provider_menu()` directly only when you need the per-tool detail the summary collapses. **How to present:** @@ -315,9 +342,9 @@ When tools are `UNAVAILABLE` but can be fixed with simple configuration, **offer - If the user declines setup, proceed with the best available path — no nagging - Group related fixes (tools sharing the same env var dependency) -### Remotion Rendering (Inside video_compose) +### Composition Runtimes (Inside video_compose) -`video_compose` has two render engines. Check which are available: +`video_compose` has **three** render engines / runtimes. They are parallel, not ranked — the choice is made at proposal and locked in `edit_decisions.render_runtime`. Check which are available: ```bash python -c " @@ -326,13 +353,17 @@ registry.discover() info = registry._tools['video_compose'].get_info() print('Render engines:', info.get('render_engines')) print('Remotion note:', info.get('remotion_note')) +print('HyperFrames note:', info.get('hyperframes_note')) " ``` | Engine | Used For | Requires | |--------|----------|----------| | **FFmpeg** | Video-only cuts, concat, trim, subtitle burn | `ffmpeg` binary (always available) | -| **Remotion** | Still images -> animated video, text cards, stat cards, charts, callouts, comparisons, transitions with spring physics | Node.js (`npx`) + `remotion-composer/` project | +| **Remotion** | React-based composition: still images → animated video, text cards, stat cards, charts, callouts, comparisons, transitions with spring physics, word-level caption burn, TalkingHead avatar | Node.js (`npx`) + `remotion-composer/` + `node_modules` | +| **HyperFrames** | HTML/CSS/GSAP composition: kinetic typography, product promos, launch reels, website-to-video, registry-block-driven scenes | Node.js ≥ 22 + FFmpeg + `npx` (consumed via `npx @hyperframes/cli`) | + +`render_runtime` is **locked at proposal** (`proposal_packet.production_plan.render_runtime`) and **carried through edit_decisions unchanged**. `video_compose` routes based on this field; silent runtime swaps are forbidden. If the chosen runtime becomes unavailable at compose time, surface a structured blocker per "Escalate Blockers Explicitly" above. See `skills/core/hyperframes.md` for the Remotion-vs-HyperFrames decision matrix. ### Critical Rule: Motion-Required Requests @@ -346,10 +377,11 @@ For any request where the deliverable inherently depends on motion rather than s For these requests: -- `Remotion` availability must be confirmed up front if the planned visual treatment depends on Remotion rendering. +- The `render_runtime` chosen at proposal (Remotion, HyperFrames, or FFmpeg) must be confirmed available up front if the planned visual treatment depends on it. - Still-image fallback is forbidden. Do not quietly convert the job into a Ken Burns teaser, animatic, or slide-based video. - FFmpeg-only fallback is forbidden when it changes the approved deliverable from motion-led video to still-led video. -- Bubble critical issues immediately. If Remotion is unavailable, fails to render, or provider clip generation fails in a way that blocks the approved treatment, stop and tell the user before proceeding. +- **Silent runtime swap is forbidden.** If `render_runtime="hyperframes"` was locked and HyperFrames is unavailable, do NOT route to Remotion instead. Surface the blocker, propose options, get user approval, log a `render_runtime_selection` decision — then proceed. +- Bubble critical issues immediately. If the chosen runtime is unavailable, fails to render, or provider clip generation fails in a way that blocks the approved treatment, stop and tell the user before proceeding. - Do not spend more tokens or time on downgraded output unless the user explicitly approves the downgrade as an animatic or proof-of-concept. **When Remotion is available**, the agent should design production plans around it: @@ -363,11 +395,11 @@ For these requests: See `remotion-composer/SCENE_TYPES.md` for the authoritative list and their cut schemas. Current scene types usable via `cut.type`: `text_card`, `stat_card`, `callout`, `comparison`, `hero_title`, `terminal_scene`, `anime_scene`, `bar_chart`, `line_chart`, `pie_chart`, `kpi_grid`, `progress_bar`. Overlay types include `section_title`, `stat_reveal`, `hero_title`, `provider_chip`. -**When Remotion is NOT available**, `video_compose` falls back to FFmpeg Ken Burns motion on still images. This still works but produces less engaging visuals. Mention this tradeoff in the proposal. +**When Remotion is NOT available** and `render_runtime="remotion"` was NOT locked, `video_compose` may use FFmpeg Ken Burns motion on still images. This still works but produces less engaging visuals. Mention this tradeoff in the proposal. When `render_runtime="remotion"` IS locked and Remotion is unavailable, that's a blocker — escalate, don't silently swap. -That fallback is only acceptable when it does not violate the approved delivery shape. If the user asked for a motion-led trailer or comparable clip-driven piece, the project is blocked until Remotion is working or the user explicitly approves a lower-fidelity alternative. +When `render_runtime="hyperframes"` is locked and HyperFrames is unavailable (Node < 22, missing `ffmpeg`/`npx`, or `hyperframes doctor` reports issues), that's also a blocker. Do not substitute Remotion or FFmpeg without user approval + a logged `render_runtime_selection` decision. -The routing is automatic — the `render` operation in `video_compose` calls `_needs_remotion()` and routes accordingly. But the **agent must know Remotion exists at proposal time** so it can design the visual approach to take advantage of it (animated text cards, component scenes, spring transitions) rather than designing around static images. +Routing is automatic — `video_compose` reads `edit_decisions.render_runtime` and dispatches to the matching engine (`_render_via_hyperframes`, `_remotion_render`, or `_render_via_ffmpeg`). But the **agent must know both Remotion and HyperFrames exist at proposal time** so it can design the visual approach intentionally. Don't default to Remotion for motion-graphics-heavy concepts that HTML/GSAP would express more naturally, and don't default to HyperFrames for briefs that reuse the existing React scene stack. ## Capability Discovery @@ -589,7 +621,9 @@ Reading order: 2. relevant pipeline or creative skill (Layer 2) — know HOW to use it in this context 3. underlying vendor skill (Layer 3) — **mandatory before calling any generation tool** -**NEVER read tool source code (`tools/*.py`) to understand how to use a tool.** Skills exist precisely so you don't need implementation details. Layer 2 tells you *what* and *when*. Layer 3 tells you *how*. If you're reading `.py` files to figure out input schemas or provider options, you're doing it wrong — that information belongs in the skill layer. +**Prefer skills over source code for tool usage.** Skills exist precisely so you don't need implementation details in the common case. Layer 2 tells you *what* and *when*. Layer 3 tells you *how*. For authoring prompts, choosing parameters, or understanding usage patterns, you should be reading skills — not `.py` files. + +**Exception: debugging, audits, and verifying the governance contract.** When a skill and a tool disagree, or when something behaves differently than the skill claims, reading the tool source is fair game — that's often the only way to catch a silent-availability bug or a stale doc string. An audit that refuses to look at the implementation will miss exactly the bugs that matter most. If you do read source to debug, consider whether the finding belongs in a skill update afterward so the next agent doesn't need to repeat the dive. **Layer 3 is not optional.** Every generation tool (video, image, TTS, music) has an `agent_skills` field listing its Layer 3 skills. These skills contain provider-specific prompt engineering, parameter tuning, and quality techniques. Read them before writing prompts. The difference between a generic prompt and a skill-informed prompt is the difference between "usable" and "cinematic." diff --git a/Makefile b/Makefile index 4960270..91021ee 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup install install-dev install-gpu test test-contracts lint clean preflight demo demo-list +.PHONY: setup install install-dev install-gpu test test-contracts lint clean preflight demo demo-list hyperframes-doctor hyperframes-warm # ---- One-command setup ---- @@ -12,11 +12,19 @@ setup: @echo "==> Installing free offline TTS (Piper)..." pip install piper-tts || echo " [skip] piper-tts install failed — TTS will use cloud providers instead" @echo "" + @echo "==> Installing HyperFrames runtime (cache-warm via npx)..." + @echo " Pulls the 'hyperframes' npm package into the local npx cache so the" + @echo " first render doesn't pay a 30-60s cold-fetch penalty. ~20MB of disk." + @npx --yes hyperframes --version >/dev/null 2>&1 && echo " HyperFrames CLI cached (npx)" || echo " [skip] HyperFrames cache-warm failed — offline or npm unavailable; first render will fetch on demand" + @python -c "from tools.video.hyperframes_compose import HyperFramesCompose; HyperFramesCompose._npm_resolve_cache=None; c=HyperFramesCompose()._runtime_check(); print(f' HyperFrames runtime_available={c[\"runtime_available\"]}, npm={c.get(\"npm_package_version\") or c.get(\"npm_resolve_error\")}'); [print(f' note: {r}') for r in c['reasons']]" || echo " [skip] HyperFrames check failed — runtime can be set up later" + @echo "" python -c "import shutil, os; e=os.path.exists('.env'); shutil.copy('.env.example','.env') if not e else None; print('==> Created .env from .env.example — add your API keys there.' if not e else '==> .env already exists — skipping.')" @echo "" @echo "Done! Open this project in your AI coding assistant and start creating." @echo " Optional: add API keys to .env to unlock cloud providers." @echo " Optional: run 'make install-gpu' if you have an NVIDIA GPU." + @echo " Optional: run 'make hyperframes-doctor' to fully validate the HyperFrames runtime." + @echo " Optional: run 'make hyperframes-warm' anytime to refresh the npx cache to the latest hyperframes version." # ---- Individual installs ---- @@ -43,6 +51,16 @@ test-contracts: preflight: python -c "from tools.tool_registry import registry; import json; registry.discover(); print(json.dumps(registry.provider_menu(), indent=2))" +hyperframes-doctor: + @echo "==> Probing HyperFrames runtime (node/ffmpeg/npx + hyperframes doctor)..." + python -c "from tools.video.hyperframes_compose import HyperFramesCompose; r=HyperFramesCompose().execute({'operation':'doctor'}); import json; print(json.dumps(r.data, indent=2)); print('OK' if r.success else f'FAIL: {r.error}')" + +hyperframes-warm: + @echo "==> Refreshing the HyperFrames npx cache to latest..." + @echo " Uses --prefer-online so npx picks up new releases since your last run." + npx --yes --prefer-online hyperframes --version + @echo "==> Cache warm complete." + demo: @echo "==> Rendering zero-key demo videos (no API keys needed)..." @echo " These use only Remotion components — animated charts, text, data viz." diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 47660f1..f713641 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -70,7 +70,12 @@ Each tool's `agent_skills[]` field bridges Layer 1 → Layer 3. See `skills/INDE | `tools/tool_registry.py` | Tool discovery and reporting | | `tools/cost_tracker.py` | Budget governance | | `tools/video/video_stitch.py` | Multi-clip assembly (stitch, spatial, validate, preview) | +| `tools/video/video_compose.py` | Runtime-aware composition orchestrator — routes to Remotion / HyperFrames / FFmpeg based on `edit_decisions.render_runtime` | +| `tools/video/hyperframes_compose.py` | HyperFrames runtime — workspace materialization, `hyperframes lint`/`validate`/`render`, FFmpeg floor check | +| `lib/hyperframes_style_bridge.py` | Playbook → CSS custom properties + `DESIGN.md` bridge for HyperFrames workspaces | | `remotion-composer/src/components/` | 8 Remotion components (TextCard, StatCard, ProgressBar, CalloutBox, ComparisonCard + charts/) | +| `.agents/skills/hyperframes*/` | Vendored HyperFrames Layer 3 skills (authoring contract, CLI, registry, website-to-video) | +| `skills/core/hyperframes.md` | Layer 2 — when OpenMontage should pick HyperFrames vs Remotion, artifact → workspace mapping | | `schemas/styles/playbook.schema.json` | Playbook schema v2 with design tokens (chart_palette, scale_system, weight_matrix, color_rules) | | `tests/qa/` | Quality validation test scripts for tool-by-tool output inspection | diff --git a/PROMPT_GALLERY.md b/PROMPT_GALLERY.md index 22a0d28..849d555 100644 --- a/PROMPT_GALLERY.md +++ b/PROMPT_GALLERY.md @@ -126,6 +126,38 @@ These use the **Animation pipeline** with `image_animation` approach — FLUX-ge --- +## HyperFrames — HTML/GSAP Motion Graphics (zero-key, ~$0) + +These use the HyperFrames composition runtime — HTML + CSS + GSAP rendered deterministically to video via headless Chrome + FFmpeg. Perfect for kinetic typography, product promos, launch reels, and website-to-video treatments where the visual grammar is typographic and motion-first. + +**Requirements:** Node.js ≥ 22, FFmpeg, `npx` — no monorepo checkout, the CLI is fetched via `npx @hyperframes/cli` on first run. + +### Kinetic Product Launch + +> "Make a 20-second product launch video for a new AI coding assistant called 'Cortex'. Big kinetic typography, three feature callouts, a bold accent color, and a final CTA card. Use the HyperFrames runtime." + +**What you get:** HTML/GSAP composition with SplitText-style word reveals, staggered feature callouts, accent-driven color accents from a custom playbook, and `hyperframes lint`/`validate` gates passed before render. + +**Estimated time:** 3-5 minutes | **Cost:** $0 + +### Website → Video Teaser + +> "Here's my landing page URL: https://example.com. Make me a 15-second social ad for Instagram. Use HyperFrames and pick up the site's real colors and typography." + +**What you get:** `website-to-hyperframes` workflow — capture the site, extract colors/typography into a `DESIGN.md`, storyboard 3-4 beats, generate narration, build compositions with GSAP timelines, lint + validate + render. + +**Estimated time:** 8-12 minutes | **Cost:** $0 (or ~$0.05 with premium TTS) + +### Launch Reel with Registry Blocks + +> "Create a 25-second launch reel for a developer tools startup. Include a data chart block (showing user growth from HyperFrames registry), kinetic title cards, and a shader transition between scenes." + +**What you get:** `hyperframes add data-chart` + `hyperframes add shader-transition` installed as sub-compositions, wired into index.html, animated with GSAP timelines. Registry blocks are HyperFrames-only; Remotion can't install them. + +**Estimated time:** 5-10 minutes | **Cost:** $0 + +--- + ## Full Setup Prompts (~$1-$3) With video generation (Veo, Kling, Runway) + premium TTS (ElevenLabs) + music (Suno). These produce broadcast-quality content. diff --git a/README.md b/README.md index fbb4d02..ba33ff3 100644 --- a/README.md +++ b/README.md @@ -204,10 +204,13 @@ You don't need paid API keys to make real videos. Out of the box, `make setup` g | **Narration** | Piper TTS | Free offline text-to-speech — real human-sounding narration | | **Open footage** | Archive.org + NASA + Wikimedia Commons | Free/open archival footage, educational media, and documentary texture | | **Extra stock** | Pexels + Unsplash + Pixabay | Free stock footage/images (developer keys are free to get) | -| **Composition** | Remotion | Turns still images into animated video with spring physics, transitions, typography, and TikTok-style captions | +| **Composition (React)** | Remotion | React-based rendering — spring-animated image scenes, text cards, stat cards, charts, TikTok-style word-level captions, TalkingHead | +| **Composition (HTML/GSAP)** | HyperFrames | HTML/CSS/GSAP rendering — kinetic typography, product promos, launch reels, registry blocks, website-to-video | | **Post-production** | FFmpeg | Encoding, subtitle burn-in, audio mixing, color grading | | **Subtitles** | Built-in | Auto-generated captions with word-level timing | +OpenMontage picks between Remotion and HyperFrames at proposal time (locked as `render_runtime`). Remotion is the default for data-driven explainers and anything using the existing React scene stack; HyperFrames is the default for motion-graphics-heavy briefs that express naturally as HTML + GSAP. See `skills/core/hyperframes.md` for the full decision matrix. + **Two free-ish paths:** - **Image-based video:** Piper narrates your script, images provide the visuals, and Remotion animates them into a polished edit. @@ -505,9 +508,12 @@ Each tool declares which Layer 3 skills it relies on. The agent reads Layer 1 to | Engine | Type | What It Does | |--------|------|-------------| -| **Remotion** | Local (Node.js) | React-based programmatic video — spring-animated image scenes, stat reveals, section titles, hero cards, TikTok-style word-by-word captions, scene transitions (fade/slide/wipe/flip), Google Fonts, and audio with fade curves. **When no video generation providers are configured, the agent generates still images and Remotion turns them into fully animated video.** | +| **Remotion** | Local (Node.js) | React-based programmatic video — spring-animated image scenes, stat reveals, section titles, hero cards, TikTok-style word-by-word captions, scene transitions (fade/slide/wipe/flip), Google Fonts, audio with fade curves, and the TalkingHead avatar composition. **When no video generation providers are configured, the agent generates still images and Remotion turns them into fully animated video.** | +| **HyperFrames** | Local (Node.js ≥ 22) | HTML/CSS/GSAP programmatic video — kinetic typography, product promos, launch reels, custom motion graphics, registry blocks (data charts, grain overlays, shader transitions), website-to-video workflows. Consumed via `npx @hyperframes/cli`; no monorepo checkout needed. | | **FFmpeg** | Local | Core video assembly, encoding, subtitle burn, audio muxing, color grading | +Runtime is chosen at proposal (`render_runtime`) and locked through `edit_decisions`. Silent swaps between runtimes are a governance violation — see `skills/core/hyperframes.md`. + --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3f60680..0491dca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -423,14 +423,33 @@ Each profile specifies codec, audio codec, CRF, pixel format, max file size, max --- -## Remotion Composer +## Composition Runtimes -A standalone Node.js/React subproject in `remotion-composer/` for programmatic video composition using [Remotion](https://www.remotion.dev/). +OpenMontage has a multi-runtime composition layer. Three engines live behind `video_compose`, chosen at proposal and locked in `edit_decisions.render_runtime`: + +### Remotion (React-based) + +A standalone Node.js/React subproject in `remotion-composer/` using [Remotion](https://www.remotion.dev/). - **React 18** + **Remotion 4.0** + **TypeScript 5.3** -- Used by `video_compose` tool for complex compositions +- Handles the existing scene-component stack (`text_card`, `stat_card`, charts, captions, `TalkingHead`, `CinematicRenderer`) - Scripts: `start` (studio), `build` (render), `upgrade` +### HyperFrames (HTML/CSS/GSAP) + +Consumed via `npx @hyperframes/cli` (no monorepo checkout needed). Runtime floor: Node.js ≥ 22, FFmpeg, `npx`. + +- Handles kinetic typography, product promos, launch reels, website-to-video, registry blocks +- Driver: `tools/video/hyperframes_compose.py` materializes a workspace under `projects//hyperframes/`, then runs `lint → validate → render` +- Layer 3 skills vendored at `.agents/skills/hyperframes*/`; Layer 2 guide at `skills/core/hyperframes.md` + +### FFmpeg (fallback / simple cuts) + +- Handles pure concat/trim when no composition is needed +- Also handles subtitle burn-in as a post-hoc operation + +`video_compose` reads `edit_decisions.render_runtime` and dispatches via `_render_via_hyperframes`, `_remotion_render`, or `_render_via_ffmpeg`. Silent runtime swaps are forbidden — the tool returns a structured blocker when the chosen runtime is unavailable. See `AGENT_GUIDE.md` → "Composition Runtimes (Inside video_compose)" and `skills/core/hyperframes.md` for the full decision matrix. + --- ## Test Architecture diff --git a/lib/hyperframes_style_bridge.py b/lib/hyperframes_style_bridge.py new file mode 100644 index 0000000..73791dd --- /dev/null +++ b/lib/hyperframes_style_bridge.py @@ -0,0 +1,194 @@ +"""Bridge OpenMontage playbooks → HyperFrames-friendly style artifacts. + +OpenMontage playbooks are YAML files describing visual_language, typography, +motion, and asset generation guidance. When `render_runtime = "remotion"` +they're translated into a `themeConfig` prop on the React composition +(see `tools/video/video_compose._build_theme_from_playbook`). When +`render_runtime = "hyperframes"` the equivalent translation produces: + +- a dict of CSS custom properties to emit on `:root` +- a short DESIGN.md the workspace can ship so humans (and agents) can + inspect the visual system in plain language + +The playbook schema is NOT forked. We read the same fields; we just render +them differently. +""" + +from __future__ import annotations + +from typing import Any + + +_FALLBACK_CSS_VARS = { + "--color-bg": "#0B0F1A", + "--color-fg": "#F5F5F5", + "--color-accent": "#F59E0B", + "--color-primary": "#2563EB", + "--color-secondary": "#10B981", + "--color-surface": "#111827", + "--color-muted": "#6B7280", + "--font-heading": "Inter", + "--font-body": "Inter", + "--font-mono": "JetBrains Mono", + "--ease-primary": "cubic-bezier(0.65, 0, 0.35, 1)", + "--ease-entrance": "cubic-bezier(0.33, 1, 0.68, 1)", + "--ease-exit": "cubic-bezier(0.32, 0, 0.67, 0)", + "--duration-entrance": "0.6s", + "--duration-transition": "0.5s", +} + + +def _first(raw: Any, default: str) -> str: + """Return the first string value from a palette entry (list or scalar).""" + if isinstance(raw, list) and raw: + return str(raw[0]) + if isinstance(raw, str) and raw: + return raw + return default + + +def _font(typo: dict[str, Any], key: str, default: str) -> str: + """Extract a font family string from a typography block.""" + node = typo.get(key) or {} + if isinstance(node, dict): + return str(node.get("font") or node.get("family") or default) + if isinstance(node, str): + return node + return default + + +def _motion_easing(motion: dict[str, Any]) -> tuple[str, str]: + """Derive (duration, ease) from the playbook motion block.""" + pace = (motion.get("pace") or "moderate").lower() + if pace == "fast": + return "0.4s", "cubic-bezier(0.33, 1, 0.68, 1)" + if pace == "slow": + return "0.9s", "cubic-bezier(0.65, 0, 0.35, 1)" + return "0.6s", "cubic-bezier(0.5, 0, 0.5, 1)" + + +def style_bridge( + playbook: dict[str, Any] | None, + edit_decisions: dict[str, Any] | None = None, +) -> tuple[dict[str, str], str]: + """Translate a playbook into (css_vars, design_md). + + Both outputs are safe to write into a HyperFrames workspace: + - `css_vars` → `:root { … }` block in index.html + - `design_md` → `DESIGN.md` at the workspace root + + When `playbook` is empty or partial, missing fields fall back to + `_FALLBACK_CSS_VARS` so the emitted HTML is always renderable. + """ + css: dict[str, str] = dict(_FALLBACK_CSS_VARS) + source_note = "built-in fallback palette" + playbook_name = "" + + if playbook: + playbook_name = str( + playbook.get("name") + or playbook.get("id") + or playbook.get("display_name") + or "" + ) + vl = playbook.get("visual_language", {}) or {} + palette = vl.get("color_palette", {}) or {} + typo = playbook.get("typography", {}) or {} + motion = playbook.get("motion", {}) or {} + + bg = _first(palette.get("background"), css["--color-bg"]) + fg = _first(palette.get("text"), css["--color-fg"]) + accent = _first(palette.get("accent"), css["--color-accent"]) + primary = _first(palette.get("primary"), css["--color-primary"]) + secondary = _first(palette.get("secondary"), css["--color-secondary"]) + surface = _first(palette.get("surface"), css["--color-surface"]) + muted = _first(palette.get("muted_text"), css["--color-muted"]) + + duration, ease = _motion_easing(motion) + + css.update( + { + "--color-bg": bg, + "--color-fg": fg, + "--color-accent": accent, + "--color-primary": primary, + "--color-secondary": secondary, + "--color-surface": surface, + "--color-muted": muted, + "--font-heading": _font(typo, "heading", css["--font-heading"]), + "--font-body": _font(typo, "body", css["--font-body"]), + "--font-mono": _font(typo, "code", css["--font-mono"]), + "--ease-primary": ease, + "--duration-entrance": duration, + } + ) + + source_note = f"playbook `{playbook_name}`" if playbook_name else "loaded playbook" + + # Edit-decisions may override colors per-production (rare but legal). + if edit_decisions: + meta = edit_decisions.get("metadata", {}) or {} + if meta.get("primary_color"): + css["--color-primary"] = str(meta["primary_color"]) + if meta.get("accent_color"): + css["--color-accent"] = str(meta["accent_color"]) + if meta.get("background_color"): + css["--color-bg"] = str(meta["background_color"]) + if meta.get("text_color"): + css["--color-fg"] = str(meta["text_color"]) + + design_md = _render_design_md(css, source_note, playbook_name) + return css, design_md + + +def _render_design_md( + css: dict[str, str], source_note: str, playbook_name: str +) -> str: + title = f"# DESIGN — {playbook_name}" if playbook_name else "# DESIGN" + lines = [ + title, + "", + f"> Generated by OpenMontage HyperFrames style bridge (source: {source_note}).", + "", + "## Colors", + "", + f"- Background: `{css['--color-bg']}`", + f"- Foreground: `{css['--color-fg']}`", + f"- Primary: `{css['--color-primary']}`", + f"- Accent: `{css['--color-accent']}`", + f"- Secondary: `{css['--color-secondary']}`", + f"- Surface: `{css['--color-surface']}`", + f"- Muted text: `{css['--color-muted']}`", + "", + "## Typography", + "", + f"- Heading: `{css['--font-heading']}`", + f"- Body: `{css['--font-body']}`", + f"- Mono: `{css['--font-mono']}`", + "", + "## Motion", + "", + f"- Primary ease: `{css['--ease-primary']}`", + f"- Entrance duration: `{css['--duration-entrance']}`", + "", + "## How to use in compositions", + "", + "Reference these tokens via `var(...)` instead of hard-coding hex", + "values or font names:", + "", + "```css", + ".scene-content h1 {", + " font-family: var(--font-heading);", + " color: var(--color-fg);", + "}", + ".scene-content .accent {", + " color: var(--color-accent);", + "}", + "```", + "", + "When adjusting a composition's look, edit the `:root` block in", + "`index.html` (or this bridge for cross-project changes), not the", + "individual scene styles.", + "", + ] + return "\n".join(lines) diff --git a/lib/slideshow_risk.py b/lib/slideshow_risk.py index 72c5596..43f507d 100644 --- a/lib/slideshow_risk.py +++ b/lib/slideshow_risk.py @@ -27,6 +27,7 @@ def score_slideshow_risk( scenes: list[dict[str, Any]], edit_decisions: dict[str, Any] | None = None, renderer_family: str | None = None, + render_runtime: str | None = None, ) -> dict[str, Any]: """Score slideshow risk across 6 dimensions. @@ -34,12 +35,19 @@ def score_slideshow_risk( scenes: Scene list from scene_plan artifact. edit_decisions: Optional edit_decisions artifact for transition analysis. renderer_family: Optional renderer family for cinematic claim verification. + render_runtime: Optional render runtime (remotion/hyperframes/ffmpeg). + Passed through so dimension scoring can reason about runtime-specific + indicators when it's useful. Scoring logic is currently + runtime-neutral — a text-heavy HyperFrames composition is just as + slideshow-y as a text-heavy Remotion one — but the parameter is + part of the contract so callers record runtime alongside family. Returns: { "average": float, "verdict": str, "dimensions": {dimension_name: {"score": float, "reason": str}}, + "render_runtime": str | None, } """ if not scenes: @@ -47,6 +55,7 @@ def score_slideshow_risk( "average": 5.0, "verdict": "fail", "dimensions": {}, + "render_runtime": render_runtime, } dimensions = { @@ -74,6 +83,7 @@ def score_slideshow_risk( "average": round(average, 2), "verdict": verdict, "dimensions": dimensions, + "render_runtime": render_runtime, } diff --git a/pipeline_defs/animated-explainer.yaml b/pipeline_defs/animated-explainer.yaml index 094a7eb..bb92a46 100644 --- a/pipeline_defs/animated-explainer.yaml +++ b/pipeline_defs/animated-explainer.yaml @@ -230,6 +230,7 @@ stages: - video_stitch tools_available: - video_compose + - hyperframes_compose - video_stitch - audio_mixer checkpoint_required: true @@ -238,6 +239,8 @@ stages: - Output video exists and is playable - "Duration within +/-5% of target" - Audio is clear with proper narration/music balance + - "render_runtime in edit_decisions matches proposal_packet — silent swap to another runtime is a CRITICAL governance violation" + - "If render_runtime='hyperframes': `hyperframes lint` and `hyperframes validate` must pass before render" success_criteria: - Schema-valid render_report with output paths - Output file exists and passes ffprobe validation diff --git a/pipeline_defs/animation.yaml b/pipeline_defs/animation.yaml index 1c1ef64..953d27f 100644 --- a/pipeline_defs/animation.yaml +++ b/pipeline_defs/animation.yaml @@ -98,6 +98,7 @@ stages: - Free/local tool options are highlighted where applicable - "Audio architecture decided: single narrator / character dialogue / narrator+characters" - "Provider choice presented as comparison table with costs — agent recommends but user decides" + - "render_runtime chosen explicitly — remotion for React scene stack, hyperframes for HTML/GSAP motion graphics, ffmpeg for simple concat. See skills/core/hyperframes.md and skills/meta/animation-runtime-selector.md for the decision matrix. Record in decision_log with category render_runtime_selection." success_criteria: - Schema-valid proposal_packet with at least 3 concept_options - selected_concept includes animation_mode and reuse_strategy @@ -243,6 +244,7 @@ stages: - video_stitch tools_available: - video_compose + - hyperframes_compose - audio_mixer - video_stitch checkpoint_required: true @@ -252,6 +254,8 @@ stages: - Motion timing survives the final render - Audio sync matches the animatic intent - Duration within +/-5% of target + - "render_runtime in edit_decisions matches render_runtime locked in proposal_packet — any swap requires a render_runtime_selection decision in decision_log" + - "HyperFrames renders MUST pass `hyperframes lint` and `hyperframes validate` before the render step — never skip validation" - "Post-render self-review MANDATORY: extract keyframes, visually inspect each scene, flag weak frames before presenting to user" success_criteria: - Schema-valid render_report artifact diff --git a/pipeline_defs/cinematic.yaml b/pipeline_defs/cinematic.yaml index c3b7594..1c089f9 100644 --- a/pipeline_defs/cinematic.yaml +++ b/pipeline_defs/cinematic.yaml @@ -233,6 +233,7 @@ stages: - audio_enhance tools_available: - video_compose + - hyperframes_compose - audio_mixer - video_stitch - video_trimmer @@ -245,6 +246,8 @@ stages: - Motion-required delivery was preserved without silent fallback - Audio dynamics are controlled and intelligible - Letterbox or frame treatment improves rather than harms the output + - "render_runtime in edit_decisions matches proposal_packet — silent swap is a CRITICAL governance violation" + - "Cinematic work typically picks render_runtime='remotion' (CinematicRenderer) for video-led pieces; render_runtime='hyperframes' for kinetic title cards and HTML/GSAP-driven trailers" success_criteria: - Schema-valid render_report artifact - Output file exists and passes ffprobe validation diff --git a/pipeline_defs/documentary-montage.yaml b/pipeline_defs/documentary-montage.yaml index cbd75c1..d1bb364 100644 --- a/pipeline_defs/documentary-montage.yaml +++ b/pipeline_defs/documentary-montage.yaml @@ -166,6 +166,7 @@ stages: - Resolution matches target_platform canvas - Uniform LUT applied across the timeline - Music is mixed in (MANDATORY — silent output only with explicit user opt-out recorded in brief) + - "render_runtime locked at proposal MUST stay 'remotion' on this pipeline until HyperFrames end-tag parity lands — the overlay/concat end-tag stack depends on Remotion CinematicRenderer components. Silent swap to hyperframes or ffmpeg is a CRITICAL governance violation." - "End-tag rendered via Remotion (MANDATORY — absence only with explicit opt-out). Default mode: overlay (ProRes 4444 with alpha composited on final scenes). Fallback: concat (opaque card appended after body)." - "Overlay mode: extract a frame from the overlay region and verify text is visible over footage, not over black. Concat mode: last frame must be the end-tag card." - No silent fallback from a motion-led promise diff --git a/pipeline_defs/hybrid.yaml b/pipeline_defs/hybrid.yaml index 1cd1b74..0de387e 100644 --- a/pipeline_defs/hybrid.yaml +++ b/pipeline_defs/hybrid.yaml @@ -187,6 +187,7 @@ stages: - audio_enhance tools_available: - video_compose + - hyperframes_compose - audio_mixer - video_stitch - video_trimmer @@ -198,6 +199,8 @@ stages: - Source and support layers remain balanced - Aspect-ratio variants preserve readability - Audio stays coherent across footage and generated elements + - "render_runtime in edit_decisions matches proposal_packet — silent swap is a CRITICAL governance violation" + - "Hybrid typically uses render_runtime='remotion' so source footage and React support overlays compose in one pass; pick 'hyperframes' only when support layers are HTML/GSAP-native" success_criteria: - Schema-valid render_report artifact - Output file exists and passes ffprobe validation diff --git a/pipeline_defs/screen-demo.yaml b/pipeline_defs/screen-demo.yaml index defc3d0..950f484 100644 --- a/pipeline_defs/screen-demo.yaml +++ b/pipeline_defs/screen-demo.yaml @@ -209,6 +209,7 @@ stages: - audio_enhance tools_available: - video_compose + - hyperframes_compose - audio_mixer - video_trimmer - audio_enhance @@ -218,6 +219,8 @@ stages: - Output video is playable and crisp at target resolution - UI text remains readable after crops and scaling - Audio is clear with no keyboard/click noise + - "render_runtime in edit_decisions matches proposal_packet — silent swap is a CRITICAL governance violation" + - "For synthetic UI/terminal demos, render_runtime='remotion' (TerminalScene) is preferred; render_runtime='hyperframes' is valid for custom HTML UIs — in that case, lint+validate must pass before render" success_criteria: - Schema-valid render_report artifact - Output file exists and passes ffprobe validation diff --git a/render_demo.py b/render_demo.py index 7c27778..1033fe5 100644 --- a/render_demo.py +++ b/render_demo.py @@ -1,3 +1,15 @@ +"""Render the curated zero-key Remotion demos. + +This script is Remotion-specific by design — the demos live in +`remotion-composer/public/demo-props/` as JSON props for existing React +scene components. It is NOT a cross-runtime demo harness. + +For a HyperFrames demo, run `make hyperframes-doctor` to verify the runtime +floor, then either scaffold a real composition via `npx hyperframes init` +or drive `hyperframes_compose` from the Agent SDK. HyperFrames demos are +authored as HTML + GSAP in a project workspace, not as JSON props here. +""" + from __future__ import annotations import argparse diff --git a/schemas/artifacts/decision_log.schema.json b/schemas/artifacts/decision_log.schema.json index f72b374..7ae2dc0 100644 --- a/schemas/artifacts/decision_log.schema.json +++ b/schemas/artifacts/decision_log.schema.json @@ -28,6 +28,7 @@ "pipeline_selection", "provider_selection", "renderer_family_selection", + "render_runtime_selection", "playbook_selection", "fallback_decision", "budget_tradeoff", diff --git a/schemas/artifacts/edit_decisions.schema.json b/schemas/artifacts/edit_decisions.schema.json index 5e93b74..8f20506 100644 --- a/schemas/artifacts/edit_decisions.schema.json +++ b/schemas/artifacts/edit_decisions.schema.json @@ -4,7 +4,7 @@ "title": "Edit Decisions", "description": "Editorial decisions produced by the Edit Agent.", "type": "object", - "required": ["version", "cuts"], + "required": ["version", "cuts", "render_runtime"], "properties": { "version": { "type": "string", "const": "1.0" }, "cuts": { @@ -192,7 +192,12 @@ "renderer_family": { "type": "string", "enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "documentary-montage", "product-reveal", "screen-demo", "presenter", "animation-first"], - "description": "Locked at proposal stage — determines which Remotion composition renders this video" + "description": "Locked at proposal stage — creative grammar for this deliverable. Preserved from proposal_packet; edit MUST NOT change without a logged decision." + }, + "render_runtime": { + "type": "string", + "enum": ["remotion", "hyperframes", "ffmpeg"], + "description": "Locked at proposal stage — technical runtime that realizes renderer_family. Edit MUST carry this forward unchanged unless a logged render_runtime_selection decision overrides it." }, "slideshow_risk_score": { "type": "object", diff --git a/schemas/artifacts/final_review.schema.json b/schemas/artifacts/final_review.schema.json index 3153d30..709e4ce 100644 --- a/schemas/artifacts/final_review.schema.json +++ b/schemas/artifacts/final_review.schema.json @@ -82,7 +82,20 @@ "description": "Delivery promise and renderer governance checks", "properties": { "delivery_promise_honored": { "type": "boolean" }, - "renderer_family_used": { "type": "string" }, + "renderer_family_used": { "type": "string", "description": "Creative grammar that actually ran" }, + "render_runtime_used": { + "type": "string", + "enum": ["remotion", "hyperframes", "ffmpeg"], + "description": "Technical runtime that actually executed the render. Must match the runtime locked at proposal unless a logged decision overrode it." + }, + "runtime_swap_detected": { + "type": "boolean", + "description": "True if the runtime that ran differs from the runtime locked in proposal_packet. An unapproved swap is a contract violation." + }, + "runtime_swap_check": { + "type": "string", + "description": "Human-readable status of the runtime-swap check: 'ok — …', 'detected — …', or 'skipped — …'. When the check was skipped, the reviewer skill is responsible for cross-artifact comparison." + }, "motion_ratio_actual": { "type": "number" }, "silent_downgrade_detected": { "type": "boolean" }, "issues": { diff --git a/schemas/artifacts/proposal_packet.schema.json b/schemas/artifacts/proposal_packet.schema.json index 90a70e3..fb97c0f 100644 --- a/schemas/artifacts/proposal_packet.schema.json +++ b/schemas/artifacts/proposal_packet.schema.json @@ -66,7 +66,7 @@ }, "production_plan": { "type": "object", - "required": ["pipeline", "stages"], + "required": ["pipeline", "stages", "render_runtime"], "properties": { "pipeline": { "type": "string", "description": "Pipeline manifest name, e.g., 'animated-explainer'" }, "playbook": { "type": "string", "description": "Selected style playbook" }, @@ -148,7 +148,12 @@ "renderer_family": { "type": "string", "enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "documentary-montage", "product-reveal", "screen-demo", "presenter", "animation-first"], - "description": "Locked at proposal stage — compose cannot change without logging a decision" + "description": "Locked at proposal stage — compose cannot change without logging a decision. This is the creative grammar, NOT the technical engine." + }, + "render_runtime": { + "type": "string", + "enum": ["remotion", "hyperframes", "ffmpeg"], + "description": "Locked at proposal stage — the technical runtime that realizes renderer_family. remotion=React scene components, hyperframes=HTML/CSS/GSAP, ffmpeg=simple concat/trim. Must be explicit and auditable; silent swaps are forbidden." }, "music_source": { "type": "object", diff --git a/skills/INDEX.md b/skills/INDEX.md index ce6f630..15a297e 100644 --- a/skills/INDEX.md +++ b/skills/INDEX.md @@ -79,6 +79,7 @@ Key capability families to look for in the output: |-------|------|---------|----------------------| | FFmpeg | `core/ffmpeg.md` | Video encoding, filtering, composition | `ffmpeg`, `video_toolkit` | | Remotion | `core/remotion.md` | React-based composition, Phase 3+ | `remotion-best-practices`, `remotion` | +| HyperFrames | `core/hyperframes.md` | HTML/CSS/GSAP composition runtime — kinetic typography, product promos, website-to-video, registry blocks | `hyperframes`, `hyperframes-cli`, `hyperframes-registry`, `website-to-hyperframes`, `gsap-core`, `gsap-timeline` | | WhisperX | `core/whisperx.md` | Transcription with word-level timestamps | `speech-to-text` | | Subtitle Sync | `core/subtitle-sync.md` | Subtitle timing and alignment | `remotion-best-practices` | | Color Grading | `core/color-grading.md` | FFmpeg color profiles, LUT workflow, accessibility | `ffmpeg` | @@ -297,7 +298,7 @@ Claude Code accesses them via symlinks in `.claude/skills/`. | Category | Installed Skills | Source | |----------|-----------------|--------| -| **Video Composition** | `remotion-best-practices`, `remotion` | `remotion-dev/skills`, `digitalsamba/claude-code-video-toolkit` | +| **Video Composition** | `remotion-best-practices`, `remotion`, `hyperframes`, `hyperframes-cli`, `hyperframes-registry`, `website-to-hyperframes` | `remotion-dev/skills`, `digitalsamba/claude-code-video-toolkit`, `heygen-com/hyperframes` | | **Video Processing** | `ffmpeg`, `video_toolkit` | `digitalsamba/claude-code-video-toolkit` | | **TTS & Audio** | `text-to-speech`, `speech-to-text`, `music`, `sound-effects`, `elevenlabs`, `agents`, `setup-api-key` | `elevenlabs/skills`, `digitalsamba/claude-code-video-toolkit` | | **Image Generation** | `flux-best-practices`, `bfl-api`, `grok-media` | `black-forest-labs/skills`, local OpenMontage skill | diff --git a/skills/core/hyperframes.md b/skills/core/hyperframes.md new file mode 100644 index 0000000..9ea4118 --- /dev/null +++ b/skills/core/hyperframes.md @@ -0,0 +1,324 @@ +# HyperFrames Skill (Layer 2) + +This is the **OpenMontage-specific** guide to HyperFrames. It explains when +OpenMontage pipelines should choose HyperFrames over Remotion, how OpenMontage +artifacts map to HyperFrames project files, and how the compose stage drives +the HyperFrames CLI. + +For raw HyperFrames knowledge (authoring contract, `data-*` attributes, GSAP +timeline rules, CLI flags, registry blocks, website-to-video), read the Layer 3 +skills: + +- `.agents/skills/hyperframes/` — composition authoring contract + GSAP rules +- `.agents/skills/hyperframes-cli/` — init, lint, validate, preview, render +- `.agents/skills/hyperframes-registry/` — `hyperframes add` + block wiring +- `.agents/skills/website-to-hyperframes/` — capture-to-video workflow + +This file teaches the bridge between the two. + +--- + +## When OpenMontage should pick HyperFrames (vs Remotion vs FFmpeg) + +OpenMontage separates two concepts: + +- **`renderer_family`** — the creative grammar (`explainer-data`, + `cinematic-trailer`, `product-reveal`, etc.). Chosen at proposal. +- **`render_runtime`** — the technical engine that realizes that grammar + (`remotion`, `hyperframes`, `ffmpeg`). Also chosen at proposal. + +Both are locked in `proposal_packet.schema.json` and carried through +`edit_decisions` unchanged unless a `render_runtime_selection` decision is +logged in `decision_log`. Silent runtime swaps are a contract violation. + +### Decision matrix + +| Scenario | Prefer | Why | +|----------|--------|-----| +| Existing explainer, React scene component stack (text_card, stat_card, chart scenes, caption overlay, TalkingHead, CinematicRenderer) | **Remotion** | These compositions already exist in `remotion-composer/`. Reusing them is free; replicating them in HTML is not. | +| Word-level caption burn / karaoke captions | **Remotion** | `remotion_caption_burn` is Remotion-specific and is NOT at parity on HyperFrames day 1. | +| Avatar / lip-sync presenter | **Remotion** | `TalkingHead` composition lives in Remotion. No HyperFrames equivalent yet. | +| Kinetic typography, heavy text motion, GSAP-native animation | **HyperFrames** | HTML/GSAP is the natural medium. Expressing this as Remotion `interpolate()` calls is slow and fragile. | +| Product promo / launch reel / marketing title card | **HyperFrames** | CSS/GSAP composition grammar matches how designers already think about these. Templates (`kinetic-type`, `product-promo`, `swiss-grid`) give a strong starting point. | +| Website-to-video / UI-driven composition | **HyperFrames** | The `website-to-hyperframes` workflow exists for exactly this. | +| Registry block needed (data chart, grain overlay, shimmer sweep, shader transition) | **HyperFrames** | The registry is HyperFrames-only. Remotion does not have `hyperframes add`. | +| Synthetic UI / fake terminal / fake browser demo | Either — depends on existing coverage | OpenMontage already ships Remotion `TerminalScene` (see `synthetic-screen-recording` Layer 3). For UI chrome beyond terminal, HyperFrames HTML is easier. | +| Pure concat / trim of source clips, no composition | **FFmpeg** | Neither Remotion nor HyperFrames add value here. | +| Remotion is not installed on this machine | **HyperFrames** (if available) or **FFmpeg** | Do not silently fall back. Tell the user before downgrading. | + +### Hard rule: present both runtimes when both are available + +The decision matrix above is input for the conversation with the user, NOT +a license to silently pick a "default." When both Remotion and HyperFrames +are available on the machine, the proposal stage MUST: + +1. Present both to the user with brief-specific pros/cons. +2. Recommend one with rationale tied to `delivery_promise` and + `visual_approach`. +3. Wait for approval. +4. Log both in `options_considered` of a `render_runtime_selection` + decision. + +See `AGENT_GUIDE.md` → "Present Both Composition Runtimes (HARD RULE)" for +the full contract and `skills/meta/reviewer.md` for the CRITICAL-finding +enforcement. + +### Hard rule: motion-required deliverables + +If the brief's `delivery_promise.motion_required` is `true` (sci-fi trailer, +cinematic teaser, hype edit, any brief whose promise depends on real motion), +then the runtime chosen at proposal is a **commitment**, not a hint. Compose +MUST NOT downgrade to FFmpeg Ken Burns. If the chosen runtime fails (Remotion +not installed, `npx hyperframes doctor` reports a blocker), surface the blocker +per `AGENT_GUIDE.md` > "Escalate Blockers Explicitly" and wait for user +approval before switching runtime. + +--- + +## What stays Remotion-only (Phase 1) + +Do **not** attempt to port these to HyperFrames on day 1. They require +dedicated parity work: + +- `remotion_caption_burn` (word-by-word burned captions) +- `TalkingHead` composition (avatar/lip-sync presenter) +- Existing documentary-montage end-tag overlay stack (relies on specific + Remotion components) +- Anything that assumes assets are staged under `remotion-composer/public/` + and consumed by existing React scene components + +For these, keep `render_runtime = "remotion"` and proceed as today. + +--- + +## Project workspace layout + +HyperFrames needs its own project workspace. Do **not** reuse +`remotion-composer/public/` — that's Remotion's shared staging directory and +mixing runtimes there causes cross-project collisions. + +``` +projects// +├── artifacts/ +├── assets/ +│ ├── images/ +│ ├── video/ +│ ├── audio/ +│ └── music/ +├── hyperframes/ ← HyperFrames runtime workspace (when selected) +│ ├── index.html ← root composition +│ ├── compositions/ ← sub-compositions and registry blocks +│ │ └── components/ ← registry components (grain overlays, etc.) +│ ├── assets/ ← symlink or copy of project assets +│ ├── hyperframes.json ← CLI config (registry URL, install paths) +│ ├── DESIGN.md ← playbook-derived visual brief (optional) +│ └── narration.wav ← TTS output, when applicable +└── renders/ + └── final.mp4 +``` + +The workspace is generated at compose time by `hyperframes_compose` from +`edit_decisions` + `asset_manifest` + the active playbook. It's regenerable +and gitignored along with the rest of `projects/`. + +### Why a dedicated workspace per project + +- HyperFrames resolves `data-composition-src`, `src=`, and registry blocks + relative to the project root. A shared workspace breaks this. +- `npx hyperframes lint | validate | render` all operate on a project + directory. They don't take an abstract composition ID the way Remotion does. +- Assets live next to the HTML that references them, matching the + `website-to-hyperframes` reference workflow. + +--- + +## Artifact → HyperFrames mapping + +When `render_runtime = "hyperframes"`, the compose stage translates +OpenMontage artifacts into HyperFrames project files: + +| OpenMontage artifact field | HyperFrames target | +|---|---| +| `edit_decisions.cuts[]` (sequence of scenes) | `index.html` timeline, one `
` per cut | +| `edit_decisions.cuts[i].in_seconds / out_seconds` | `data-start` / `data-duration` on the clip element | +| `edit_decisions.cuts[i].type` (scene kind) | Registry block installed via `hyperframes add`, OR a hand-authored sub-composition template | +| `asset_manifest.assets[]` paths | Copied or symlinked into `projects/

/hyperframes/assets/` and referenced with relative `src=` | +| `audio.narration.segments[]` | `

/artifacts/`. Treat the upstream files as **convenience copies** +written into the HyperFrames workspace so the runtime workflow feels natural: + +- `DESIGN.md` — derived from the selected playbook, written by + `hyperframes_compose` or `lib/hyperframes_style_bridge.py`. Safe to use as + a working brief in the workspace. +- `SCRIPT.md` — optional narration copy for human review. Canonical script + stays in `artifacts/script.json`. +- `STORYBOARD.md` — optional per-beat creative direction. Canonical scene + plan stays in `artifacts/scene_plan.json`. + +If a workspace-local file and a canonical artifact disagree, the canonical +artifact wins. + +--- + +## Runtime selection rules + +1. **Proposal stage** chooses `render_runtime` and logs the decision in + `decision_log` with category `render_runtime_selection`. It must consider + the decision matrix above and the actual availability of each runtime. +2. **Preflight** reports which runtimes are available (see below). A + runtime that is not available is not a valid proposal choice unless the + user explicitly approves installing it. +3. **Edit stage** carries `render_runtime` forward unchanged. +4. **Compose stage** reads `edit_decisions.render_runtime` and routes via + `video_compose` → `hyperframes_compose` (for HyperFrames) or the existing + Remotion path (for Remotion). Compose may not swap runtime without a new + `render_runtime_selection` decision. +5. **Final review** records `render_runtime_used` and sets + `runtime_swap_detected = true` if it differs from proposal. + +--- + +## Preflight — HyperFrames availability + +At preflight, the provider menu reports HyperFrames availability. The +`hyperframes_compose` tool's `get_info()` returns: + +```json +{ + "runtime_available": true | false, + "node_major": 22, + "ffmpeg_available": true, + "doctor_ok": true, + "install_instructions": "…" +} +``` + +Floor requirements (all must hold for `runtime_available: true`): + +- Node.js major version ≥ 22 +- `ffmpeg` binary on PATH +- `npx` on PATH (bundled with Node.js) +- `npx hyperframes doctor` exits 0, OR a lightweight equivalent check passes + +`bun` is NOT required — HyperFrames is consumable via `npx hyperframes` (published npm package name is `hyperframes`; the monorepo-internal `@hyperframes/cli` name is NOT on the public npm registry and returns 404). + +When `runtime_available: false`, preflight must surface the reason and the +install instructions. Per `AGENT_GUIDE.md` Setup Offer Protocol, group the +fix by effort: + +- Missing Node 22 → 5-minute install, explain what it unlocks +- Missing FFmpeg → 1-minute install on macOS/Linux, longer on Windows +- `doctor` reports issues → show the doctor output verbatim + +--- + +## Validation protocol + +HyperFrames ships a real validation stack. Run **all** of these before +declaring a render complete: + +1. **`npx hyperframes lint`** — static contract checks (duplicate ids, + overlapping tracks, missing `data-composition-id`, unregistered timelines). + MUST pass before render. +2. **`npx hyperframes validate`** — browser-based runtime checks: seeks into + the paused composition, screenshots, samples pixels, computes WCAG + contrast ratios, verifies `window.__timelines` registration and + `class="clip"` on timed elements. MUST pass before render (contrast can + be deferred with `--no-contrast` during iteration, but not for final). +3. **`npx hyperframes render --quality standard`** — produces the MP4. +4. **Post-render final review** — probe with ffprobe, sample frames, + transcribe audio, compare to script. Same contract as the Remotion path. + See `final_review.schema.json`. + +If lint or validate fails, do **not** render. Fix the composition and re-run. +Silent render from a failing composition is a contract violation — the whole +point of HyperFrames is that validate catches issues that FFmpeg or Remotion +cannot. + +--- + +## Style bridge (playbook → CSS) + +OpenMontage playbooks currently translate into Remotion `themeConfig` +objects. For HyperFrames, the equivalent translation produces: + +- A block of CSS custom properties on `:root` (`--color-bg`, `--color-fg`, + `--color-accent`, `--font-heading`, `--font-body`, `--ease-primary`, + `--duration-primary`, etc.). +- A short `DESIGN.md` that explains the visual system in plain language. +- Optional typography `@import` statements (only for fonts the HyperFrames + font compiler supports). + +See `lib/hyperframes_style_bridge.py`. Playbooks do NOT need to fork — the +existing playbook schema carries enough information to drive both Remotion +and HyperFrames output. + +--- + +## Cost model + +HyperFrames renders are local: $0 API cost, but CPU-intensive (headless +Chrome + FFmpeg). Track via `cost_tracker`: + +- `estimate` — based on composition duration × resolution × `--workers` +- `reserve` — 0 (no API spend) +- `reconcile` — wall-clock render time + +Same pattern as Remotion. + +--- + +## Anti-patterns + +- ❌ Forking playbook data for HyperFrames when the current schema already + carries colors, typography, and motion. +- ❌ Writing HyperFrames compositions that reference `remotion-composer/public/` + — the HyperFrames workspace is separate and self-contained. +- ❌ Running `hyperframes init` from the OpenMontage orchestrator. `init` + creates its own project semantics and installs agent skills — it's meant + for humans bootstrapping a project, not for the pipeline. `hyperframes_compose` + generates the project files directly. +- ❌ Using HyperFrames as "React without Remotion." HyperFrames is + HTML-first with GSAP. If your scene is authored as React JSX, it belongs + in Remotion. +- ❌ Using `repeat: -1` in GSAP inside HyperFrames. Infinite tweens break the + deterministic seek-and-capture render. Always bounded repeats. +- ❌ Animating from `async` context, `setTimeout`, or Promises during + timeline construction. `window.__timelines` must be fully populated + synchronously after page load. + +--- + +## Pipelines adopting HyperFrames + +| Pipeline | Status | +|----------|--------| +| `animation` | Wave 1 — HyperFrames is a first-class option for motion-graphics-heavy briefs | +| `animated-explainer` | Wave 1 — HyperFrames viable when the concept is HTML/GSAP-native; Remotion remains default for data-chart-heavy explainers | +| `screen-demo` | Wave 1 — HyperFrames viable for synthetic product UI; `TerminalScene` (Remotion) remains preferred for terminal-specific demos | +| `cinematic` | Wave 2 | +| `hybrid` | Wave 2 | +| `documentary-montage` | Wave 2 | +| `talking-head` | Deferred — depends on TalkingHead parity | +| `avatar-spokesperson` | Deferred — depends on TalkingHead parity | +| `clip-factory`, `podcast-repurpose`, `localization-dub` | Deferred — current compose paths rely on Remotion caption burn | +| `framework-smoke` | N/A (test pipeline) | + +Proposal and compose directors for adopted pipelines describe runtime choice +explicitly — see each pipeline's `proposal-director.md` and +`compose-director.md`. diff --git a/skills/creative/animation-pipeline.md b/skills/creative/animation-pipeline.md index 3939385..e99f55e 100644 --- a/skills/creative/animation-pipeline.md +++ b/skills/creative/animation-pipeline.md @@ -1,8 +1,27 @@ # Animation & Motion Graphics Pipeline > Sources: School of Motion curriculum, After Effects documentation, Remotion documentation, -> Disney's 12 Principles of Animation (Frank Thomas & Ollie Johnston), Motion Design School, -> The Animator's Survival Kit (Richard Williams) +> HyperFrames documentation, Disney's 12 Principles of Animation (Frank Thomas & Ollie +> Johnston), Motion Design School, The Animator's Survival Kit (Richard Williams) + +## Runtime Choice — Remotion vs HyperFrames + +Animation work in OpenMontage runs on one of two composition runtimes. Both +are first-class; the choice is creative, not a fallback: + +- **Remotion (React-based)** — when the scene is a React component, uses the + existing chart/text-card/comparison/kpi stack, or needs pixel-accurate + frame-level interpolation through `useCurrentFrame()` + `interpolate()`. + Default for data-heavy explainers. +- **HyperFrames (HTML/GSAP)** — when the motion is expressed naturally as + CSS + GSAP timelines: kinetic typography, product promos, launch reels, + website/UI-driven compositions, registry-block-driven scenes. Default + when the brief is motion-graphics-led and the scene library in + `remotion-composer/` doesn't already cover the look. + +See `skills/core/hyperframes.md` and `skills/meta/animation-runtime-selector.md` +for the full decision matrix. Whichever runtime is chosen at proposal must +be locked in `edit_decisions.render_runtime` and preserved through compose. ## Quick Reference Card diff --git a/skills/meta/animation-runtime-selector.md b/skills/meta/animation-runtime-selector.md index c5c2571..030db9c 100644 --- a/skills/meta/animation-runtime-selector.md +++ b/skills/meta/animation-runtime-selector.md @@ -1,18 +1,67 @@ # Animation Runtime Selector -Meta-skill that answers: *"for the animated scene I'm about to build, which runtime + Layer 3 skills should I reach for?"* +Meta-skill that answers two questions: -Read this before authoring any animated Remotion component or HyperFrames composition. It routes you to the right Layer 3 skill so you don't waste time hand-rolling what a plugin already solves. +1. **Which composition runtime should this video use?** — Remotion, HyperFrames, or FFmpeg. +2. **Which animation library / Layer 3 skills should this scene reach for?** — Remotion primitives, GSAP plugins, framer-motion, Lottie, Manim, D3. + +Read this before authoring any animated component or composition, and whenever you're choosing `render_runtime` at proposal time. It routes you to the right Layer 3 skill so you don't waste time hand-rolling what a plugin already solves. ## When to use this skill Apply when: +- **Proposal stage** needs to lock `render_runtime` (remotion / hyperframes / ffmpeg) - A stage director (asset, edit, compose) needs to author an animated component - An agent is about to write Remotion JSX for a scene that involves text reveals, SVG motion, curved camera paths, shape morphs, or multi-stage choreography - An agent is asked to build a HyperFrames composition - An agent is uncertain whether to reach for a GSAP plugin vs inline `interpolate()`/`spring()` -## Decision matrix +## Runtime choice (Remotion vs HyperFrames vs FFmpeg) + +OpenMontage separates creative grammar (`renderer_family`) from technical +engine (`render_runtime`). Both are locked at proposal and carried through +`edit_decisions` unchanged. Silent runtime swaps at compose time are a +contract violation. + +### HARD RULE — present both runtimes, don't silently default + +When both Remotion AND HyperFrames are available on the machine (check +`video_compose.get_info()["render_engines"]`), the agent MUST present both +options to the user before locking `render_runtime`. The decision matrix +below is the agent's input for the conversation, NOT a license to silently +pick the "default" entry. See `AGENT_GUIDE.md` → "Present Both Composition +Runtimes" for the full contract. + +Concretely, at the proposal stage: + +1. Query `video_compose.get_info()["render_engines"]` to find which + runtimes are available on this machine. +2. If both Remotion and HyperFrames are available, present both to the + user with: one-line description tailored to the brief, one-line + honest tradeoff, agent's recommendation with reason. +3. Wait for explicit user approval. +4. Log the decision in `decision_log` with category + `render_runtime_selection` and both runtimes in `options_considered`. +5. Only then write `render_runtime` into `proposal_packet.production_plan`. + +A `render_runtime_selection` decision with only one option considered +when both were available is a CRITICAL reviewer finding. + +| Brief characteristic | `render_runtime` | Read | +|---|---|---| +| Existing React scene stack (text_card, stat_card, chart, caption overlay, TalkingHead, CinematicRenderer) | **remotion** | `skills/core/remotion.md` | +| Word-level caption burn / karaoke captions | **remotion** | `skills/core/remotion.md` | +| Avatar / lip-sync / presenter | **remotion** | `skills/core/remotion.md` | +| Kinetic typography, HTML/GSAP-native motion, product promo, launch reel | **hyperframes** | `skills/core/hyperframes.md` + `.agents/skills/hyperframes/SKILL.md` | +| Website → video, UI-driven composition | **hyperframes** | `.agents/skills/website-to-hyperframes/SKILL.md` | +| Registry block needed (data-chart, grain-overlay, shader transitions, etc.) | **hyperframes** | `.agents/skills/hyperframes-registry/SKILL.md` | +| Pure concat / trim of source clips, no composition needed | **ffmpeg** | `skills/core/ffmpeg.md` | +| Selected runtime is unavailable | **escalate** — do not substitute silently | `AGENT_GUIDE.md` → Escalate Blockers | + +Read `skills/core/hyperframes.md` for the full Remotion-vs-HyperFrames +decision matrix and the list of features that stay Remotion-only in Phase 1. + +## Animation library decision matrix | Animation need | Recommended runtime | Read first | |---|---|---| @@ -32,7 +81,9 @@ Apply when: | Mathematical / scientific visualization | Manim | `.agents/skills/manim-composer`, `.agents/skills/manimce-best-practices` | | D3 data-driven visualization | D3 | `.agents/skills/d3-viz` | | Data chart (bar/line/pie/KPI) | Remotion built-in chart components | `remotion-composer/SCENE_TYPES.md` | -| HyperFrames composition (any motion) | HyperFrames + GSAP (mandatory) | `.agents/skills/gsap-core`, `.agents/skills/gsap-timeline` | +| HyperFrames composition (any motion) | HyperFrames + GSAP (mandatory) | `.agents/skills/hyperframes` + `.agents/skills/gsap-core`, `.agents/skills/gsap-timeline` | +| HyperFrames composition CLI work (lint/validate/render) | HyperFrames CLI | `.agents/skills/hyperframes-cli` | +| HyperFrames registry block install (`hyperframes add ...`) | HyperFrames registry | `.agents/skills/hyperframes-registry` | ## The "keep it simple" bias diff --git a/skills/meta/onboarding.md b/skills/meta/onboarding.md index 28e9b65..293ab33 100644 --- a/skills/meta/onboarding.md +++ b/skills/meta/onboarding.md @@ -40,12 +40,38 @@ Based on discovery, classify the setup: | Tier | What's Available | Best Pipelines | |------|-----------------|----------------| -| **Zero-key** | Piper TTS + Pexels/Pixabay stock (if keys added) + Remotion + FFmpeg | Animated Explainer (stock visuals + free narration) | -| **Starter** | One configured image generation provider + free TTS + Remotion | Animated Explainer, Animation (AI-generated visuals) | +| **Zero-key** | Piper TTS + Pexels/Pixabay stock (if keys added) + Remotion and/or HyperFrames + FFmpeg | Animated Explainer (stock visuals + free narration) | +| **Starter** | One configured image generation provider + free TTS + Remotion and/or HyperFrames | Animated Explainer, Animation (AI-generated visuals) | | **Standard** | Image gen + TTS + music gen | Animated Explainer, Animation, Screen Demo, Hybrid | | **Full** | Video gen + image gen + premium TTS + music | All pipelines including Cinematic, Avatar, Talking Head | | **Full + GPU** | Cloud APIs + local video gen models | All pipelines with free local fallbacks | +**Composition runtimes** — both are first-class and surface as distinct +entries in the provider menu. Report each one's availability separately: + +- **Remotion** requires Node.js + `npx` + `remotion-composer/` + `node_modules`. + Best for React-based scene components (text cards, stat cards, charts), + word-level captions, and the `TalkingHead` avatar composition. +- **HyperFrames** requires Node.js ≥ 22 + `npx` + FFmpeg. Consumed via + `npx @hyperframes/cli` (no monorepo checkout required). Best for + HTML/CSS/GSAP motion graphics — kinetic typography, product promos, + launch reels, website-to-video workflows, registry blocks. + +Name BOTH runtimes explicitly in the "Ready to go" summary when both are +available — not "Remotion" alone. A fresh-session agent that doesn't +mention HyperFrames by name will fail to present it at proposal time; +naming it here sets the expectation that the agent is runtime-agnostic. + +If only one is available, note it in the summary and mention what the +other would unlock. If neither is available, tell the user their options +are FFmpeg-only (simple concat/trim) and what's needed to unlock HTML/React +composition. + +**Do NOT pick a runtime during onboarding.** Runtime selection happens at +the proposal stage, after the agent understands the brief. During +onboarding you're reporting capabilities, not making production decisions. +See `AGENT_GUIDE.md` → "Present Both Composition Runtimes (HARD RULE)". + ### Step 3: Greet and Orient Present a **short, friendly capability summary**. Do NOT dump the raw provider menu. Instead, translate it into plain language. diff --git a/skills/meta/reviewer.md b/skills/meta/reviewer.md index 95002bf..43f6235 100644 --- a/skills/meta/reviewer.md +++ b/skills/meta/reviewer.md @@ -108,7 +108,7 @@ Structure your review as: | Stage | What matters most | |-------|-----------------| | research | Source diversity, claim verifiability, visual reference quality | -| proposal | Delivery promise clarity, renderer family selection, music/voice plan, decision log started | +| proposal | Delivery promise clarity, renderer family AND render runtime selection, music/voice plan, decision log started | | idea | Hook uniqueness, research depth, angle diversity | | script | Timing accuracy, narrative arc, enhancement cue density | | scene_plan | Full coverage, visual variety, asset feasibility, slideshow risk score | @@ -220,6 +220,18 @@ Run at **scene_plan** and **edit** stages. Prevents the "every video looks the s - Does `renderer_family` in edit_decisions match what was set at proposal? - If changed without documented reason in decision log → **CRITICAL** +5. **Render runtime match** (edit and compose stages): + - `render_runtime` in edit_decisions must match proposal_packet.production_plan.render_runtime + - If changed without a `render_runtime_selection` decision logged in decision_log → **CRITICAL** + - At compose stage, `final_review.checks.promise_preservation.runtime_swap_detected` must be `false`. If `true` without an approved `render_runtime_selection` decision → **CRITICAL** + - Runtime unavailable at compose time is not an excuse for silent swap — the correct behavior is to escalate, get approval, log a decision, then run. + +6. **Runtime selection presented both options** (proposal stage, MANDATORY): + - Query `video_compose.get_info()["render_engines"]`. If both `remotion` and `hyperframes` show `True`, the `render_runtime_selection` decision in `decision_log` MUST have BOTH runtimes in `options_considered`. + - A `render_runtime_selection` with only one runtime in `options_considered` when both were available on the machine → **CRITICAL**. The agent silently defaulted; the user was not presented the alternative. Re-open the proposal stage and present both. + - If only one runtime was available, `options_considered` must still list the unavailable one with `rejected_because: "runtime not available on this machine"` — otherwise the audit trail loses the fact that the choice was constrained, not discretionary. + - Per AGENT_GUIDE.md > "Present Both Composition Runtimes (HARD RULE)": the pipeline's suggested "default" runtime is NOT a license to skip the conversation with the user. + ## Delivery Promise Review Run at **edit** and **compose** stages. Uses `lib/delivery_promise.py`. diff --git a/skills/pipelines/animation/compose-director.md b/skills/pipelines/animation/compose-director.md index 553e78e..ee95385 100644 --- a/skills/pipelines/animation/compose-director.md +++ b/skills/pipelines/animation/compose-director.md @@ -4,6 +4,22 @@ Render the animation with an emphasis on text sharpness, timing integrity, and consistent output cadence. For `image_animation` approach, this stage also includes building the composition JSON, sourcing music, running pre-render validation, and performing post-render self-review. +## Runtime Routing (MANDATORY first step) + +Before any other work, read `edit_decisions.render_runtime`. It was locked at proposal and MUST NOT be changed silently. The rest of this skill assumes `render_runtime="remotion"` (the default for this pipeline). If the proposal locked a different runtime: + +- **`render_runtime="hyperframes"`** — HTML/CSS/GSAP render. Do NOT follow the Remotion-specific sections below (public/ staging, Remotion composition JSON). Instead: + 1. Read `skills/core/hyperframes.md` for the full routing model. + 2. Read `.agents/skills/hyperframes/SKILL.md` and `.agents/skills/hyperframes-cli/SKILL.md` for authoring contract and CLI usage. + 3. Call `video_compose` with `edit_decisions.render_runtime="hyperframes"` — it delegates to `hyperframes_compose`, which owns workspace materialization under `projects//hyperframes/`, runs `hyperframes lint → validate → render`, and returns the MP4 path. + 4. `hyperframes lint` and `hyperframes validate` MUST both pass before render. Never skip validate; contrast can be deferred with `skip_contrast=true` during iteration but not for final delivery. +- **`render_runtime="ffmpeg"`** — simple concat/trim with no composition. Call `video_compose` directly; it will not auto-upgrade to Remotion. +- **Runtime unavailable** — do NOT silently swap to a different engine. Surface the blocker to the user per AGENT_GUIDE.md > "Escalate Blockers Explicitly" and wait for approval (recorded as a `render_runtime_selection` decision in decision_log) before switching. + +The post-render self-review (final_review) is identical across runtimes — same ffprobe probe, frame sampling, audio spotcheck, and promise preservation checks. `final_review.checks.promise_preservation.render_runtime_used` must equal the runtime that actually ran. + +**Pass `proposal_packet` to `video_compose.execute()`** when you invoke it. That lets the tool directly compare the proposal-locked runtime against the runtime recorded in `edit_decisions` and flip `runtime_swap_detected=true` if they diverge. Without it, the check is `skipped` and the reviewer skill has to catch swaps via cross-artifact comparison instead. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/animation/proposal-director.md b/skills/pipelines/animation/proposal-director.md index 4565f58..5dc7bde 100644 --- a/skills/pipelines/animation/proposal-director.md +++ b/skills/pipelines/animation/proposal-director.md @@ -8,6 +8,34 @@ You are the **Proposal Director** for a generated animation video. You sit betwe Animation proposals have a unique dimension: **animation mode selection**. Unlike explainer videos where the visual approach is secondary to the narrative, animation videos ARE their visual approach. The mode choice (Manim vs Remotion vs AI video vs motion graphics) fundamentally shapes the entire production. +## Runtime Selection (required field — `render_runtime`) + +Animation proposals must lock **both** a `renderer_family` (creative grammar) and a `render_runtime` (technical engine). These are separate concepts now that HyperFrames is a first-class runtime. Read `skills/meta/animation-runtime-selector.md` and `skills/core/hyperframes.md` for the decision matrix, and `AGENT_GUIDE.md` → "Present Both Composition Runtimes (HARD RULE)" for the governance contract. + +**MANDATORY workflow — present both runtimes, don't silently default:** + +1. Query `video_compose.get_info()["render_engines"]`. If both `remotion` and `hyperframes` are `True`, proceed to step 2. If only one is available, go to step 4 with just that one. +2. Present both runtimes to the user with brief-specific analysis: + - **Remotion** — one line on fit (e.g. "your brief uses data-chart and stat_card heavily, both already exist as React components"), one line on tradeoff (e.g. "React component authoring is more rigid than HTML/CSS for custom typographic motion"). + - **HyperFrames** — one line on fit (e.g. "the kinetic-typography opener fits HTML + GSAP better than Remotion interpolation"), one line on tradeoff (e.g. "no word-level caption burn parity yet; no access to existing Remotion chart library"). +3. Recommend one with rationale tied to the brief's `delivery_promise`, the selected animation mode, and the reuse strategy from research. +4. Wait for explicit user approval. Do NOT write `render_runtime` into `proposal_packet.production_plan` before approval. +5. Log a `render_runtime_selection` decision in `decision_log` with BOTH runtimes in `options_considered`, the user's pick as `selected`, and the rationale as `reason`. If a runtime was unavailable, record it as rejected with `rejected_because: "runtime not available on this machine"`. + +Fit cheat-sheet for the recommendation (NOT an auto-decision): + +| Brief characteristic | Lean toward | +|----------------------|-------------| +| Data-chart-heavy, text_card/stat_card/kpi_grid dominant | Remotion | +| MathAnimate / Manim scene in the animatic | Remotion (Manim renders to a video, composed in Remotion) | +| Kinetic typography, product promo, launch reel, HTML/GSAP-native motion | HyperFrames | +| Website-to-video or UI-driven composition | HyperFrames | +| Registry blocks needed (data-chart, grain-overlay, shader transitions) | HyperFrames | +| Word-level/karaoke caption burn required | Remotion (HyperFrames caption parity deferred) | +| Simple source-footage concat, no composition | ffmpeg | + +A `render_runtime_selection` decision with only one option considered when both were available is a CRITICAL reviewer finding. That's how the moat collapses into "everything looks like our chart stack." + ## Prerequisites | Layer | Resource | Purpose | @@ -95,50 +123,51 @@ This is the key differentiator from the explainer proposal. **Present the user w #### Step 3a: Tool Availability Scan -Before designing concepts, scan what's available and present it honestly: +Before designing concepts, scan what's available and present it honestly. **Do NOT hardcode provider names, costs, or key names in this output** — they drift. Read them live from the registry: + +```python +from tools.tool_registry import registry +registry.discover() +summary = registry.provider_menu_summary() # see AGENT_GUIDE.md > Mandatory Preflight +``` + +Then render the scan from `summary`, grouping by capability. Example shape you should **generate from the registry**, not copy: ``` TOOL AVAILABILITY SCAN ────────────────────── -Image generation: - ✅ FLUX (fal.ai) — FAL_KEY detected — $0.03-0.05/image - ❌ gpt-image-1 — OPENAI_API_KEY missing — $0.13/image - ❌ Stable Diffusion — Not installed locally — Free - ❌ FLUX (local) — Not installed locally — Free - -Video generation: - ❌ Runway Gen-3 — No API key — $0.50/clip - ❌ Kling — No API key — $0.10-0.30/clip - ❌ CogVideoX (local) — Not installed — Free - -Composition: - ✅ Remotion — Installed — Free (local CPU) - ✅ FFmpeg — Installed — Free - -Audio: - ✅ Pixabay Music — No key needed — Free - ❌ OpenAI TTS — OPENAI_API_KEY missing — $0.015/min - ✅ Local TTS (piper) — Not checked — Free - -Math/Diagram: - ❌ ManimCE — Not installed — Free - ✅ diagram_gen — Available — Free +Image generation: {configured}/{total} + ✅ {tool_name} ({provider}) — available + ❌ {tool_name} ({provider}) — {install_instructions trimmed to one line} +Video generation: {configured}/{total} + ... +Composition runtimes: {ffmpeg} / {remotion} / {hyperframes} + See AGENT_GUIDE.md > "Present Both Composition Runtimes (HARD RULE)". +Audio: {configured}/{total} +Math/Diagram: {configured}/{total} ``` +**Rules for this output:** +- Every name, provider, cost, and install instruction comes from `provider_menu_summary()` or `provider_menu()`. Don't type them from memory — provider surfaces change between releases. +- Never cite a cost that isn't live in the tool's `estimate_cost` or install metadata. +- Composition runtimes are a separate section because the "Present Both" HARD RULE needs all three engines visible. + **Present this scan to the user.** Say: "Here's what I can see right now. Based on this, here are your animation approach options." #### Step 3b: Animation Approach Decision Matrix Present the approaches as clear options: -| Approach | What It Looks Like | Tools Required | Cost Range | Proven? | -|----------|-------------------|----------------|------------|---------| -| **A: Image-Based Animation (Remotion)** | AI-generated keyframes with crossfade, camera motion, particles. Looks like moving anime/illustration. | `image_selector` (any provider) + Remotion | $0.03-0.13/image × 2-3/scene | ✅ Proven (mori-no-seishin) | -| **B: Clip-Based Video** | AI-generated video clips assembled as a story. Most cinematic but least consistent. | `video_selector` (Runway/Kling/etc.) | $0.10-0.50/clip × scenes | ❌ Not yet proven | +| Approach | What It Looks Like | Tools Required | Cost | Proven? | +|----------|-------------------|----------------|------|---------| +| **A: Image-Based Animation (Remotion)** | AI-generated keyframes with crossfade, camera motion, particles. Looks like moving anime/illustration. | `image_selector` (any provider) + Remotion | Pull per-image cost from the chosen provider's `estimate_cost`; 2-3 images per scene is typical | ✅ Proven (mori-no-seishin) | +| **B: Clip-Based Video** | AI-generated video clips assembled as a story. Most cinematic but least consistent. | `video_selector` routing to whichever provider is available | Pull per-clip cost from the chosen provider's `estimate_cost`; varies widely between providers | ❌ Not yet proven | | **C: Programmatic Animation (Manim)** | Code-driven math/geometry animation. Precise, clean, 3Blue1Brown style. | `math_animate` (ManimCE) | Free (local) | ❌ Not yet proven | | **D: Data Visualization (Remotion)** | Animated charts, KPIs, kinetic typography. Data-driven storytelling. | Remotion (built-in components) | Free (local) | ✅ Proven (zero-key formula) | -| **E: Diagram + Image Stills** | Process flows and architecture diagrams with Ken Burns. | `diagram_gen` + `image_selector` | $0-0.05/image | ✅ Proven | -| **F: Mixed Mode** | Combine any of the above per-scene. Most flexible. | Multiple tools | Varies | Partial | +| **E: Diagram + Image Stills** | Process flows and architecture diagrams with Ken Burns. | `diagram_gen` + `image_selector` | `diagram_gen` is free; per-image cost from `image_selector`'s routed provider | ✅ Proven | +| **F: Mixed Mode** | Combine any of the above per-scene. Most flexible. | Multiple tools | Sum per-scene from each tool's `estimate_cost` | Partial | + +**Rule:** do NOT fill in a dollar figure in the Cost column from memory. Read every cost live via `estimate_cost()` or `provider_menu_summary()` at proposal time. Provider pricing changes between releases. **For each viable approach, present to the user:** @@ -150,15 +179,15 @@ camera motion (zoom, pan, ken-burns) and particle overlays (fireflies, mist, sparkles). Creates the illusion of movement from still frames. You need: An image generation API key. - → You already have: FAL_KEY (FLUX at $0.05/image) - → Alternative: Install Stable Diffusion locally (free, slower) - → Alternative: Add OPENAI_API_KEY for gpt-image-1 ($0.13/image) + → You already have: {from provider_menu_summary: available image_generation providers} + → Alternative: {from setup_offers: 1-env-var image_generation tools} + → Alternative: local Stable Diffusion (see local_diffusion tool install_instructions) -Estimated cost for 30s video: ~$0.65 (13 images) -Estimated cost for 5min video: ~$6.00 (120 images) +Estimated cost for 30s video: pull per-image costs from each provider's +`estimate_cost` (do NOT hardcode — they drift between releases). -Style options: anime-ghibli, painterly, photorealistic, watercolor -Reference: remotion-composer/public/demo-props/mori-no-seishin.json +Style options: depend on the picked provider; read from playbook and + provider-specific Layer 3 skill (e.g. `.agents/skills/flux-best-practices`). APPROACH B: Clip-Based Video ───────────────────────────── @@ -166,11 +195,13 @@ What it looks like: AI-generated 3-5 second video clips assembled as a story. Most cinematic output but hardest to maintain visual consistency across clips. You need: A video generation API key. - → Currently available: None detected - → To enable: Add RUNWAY_API_KEY, KLING_API_KEY, or install CogVideoX locally + → Currently available: {from provider_menu_summary: available video_generation providers} + → To enable: {from setup_offers: 1-env-var video_generation tools}, or + install a local video model (see video_selector fallback_tools). -Estimated cost for 30s video: $3-15 depending on provider -Estimated cost for 5min video: $30-150 +Estimated cost for 30s video: pull from each provider's `estimate_cost` on the + actual clip plan — per-clip costs range widely between providers and change + often. Do NOT hardcode. Note: This approach is not yet proven in the OpenMontage pipeline. Consistency across clips is the #1 challenge. @@ -178,6 +209,11 @@ Note: This approach is not yet proven in the OpenMontage pipeline. **Critical principle: Surface capabilities, don't hide limitations.** The user should know exactly what's possible right now vs. what needs setup. +**Rules for this section — same as Step 3a:** +- Every provider name, env var, and cost comes from `provider_menu_summary()` or a tool's live `install_instructions` / `estimate_cost`. +- The `{placeholder}` tokens above are for the agent to fill from the registry, not paste literally. +- If you find yourself typing a specific API-key env-var name or a per-unit dollar cost into this section, stop. Those drift between releases; hardcoding them in a director skill is a governance regression (see AGENT_GUIDE.md on hardcoded provider names). Pull the same data from the registry instead. + #### Step 3c: Mode Selection Rules - If the topic is visual/artistic (anime, illustration, fantasy) → **Approach A** (image-based) @@ -403,7 +439,7 @@ Validate the `proposal_packet` artifact against `schemas/artifacts/proposal_pack ## Common Pitfalls - **Not showing the Tool Availability Scan**: The user must know what's available BEFORE seeing concepts. Don't hide missing keys or tools. -- **Ignoring animation approach feasibility**: If FLUX isn't available, don't propose image_animation without saying "you need to add FAL_KEY first." Design around constraints OR explicitly state what's needed. +- **Ignoring animation approach feasibility**: If the routed image/video provider isn't available, don't propose that approach without explicitly telling the user what's needed. Read each missing tool's `install_instructions` from the registry (do NOT hardcode specific env var names here — they drift). Design around constraints OR explicitly state what's needed. - **Three versions of the same concept with different titles**: Structural diversity means different animation approaches, different narrative structures, different hooks. - **Not leveraging free tools**: Animation has a huge cost advantage — Manim, Remotion data-viz, and diagram_gen are free. If proposing expensive AI video, justify why free alternatives won't work. - **Over-promising visual complexity**: 20 unique hand-crafted scenes is not realistic. Design reuse strategies that look varied but share underlying templates. diff --git a/skills/pipelines/avatar-spokesperson/compose-director.md b/skills/pipelines/avatar-spokesperson/compose-director.md index 0f4ea4f..030bdf8 100644 --- a/skills/pipelines/avatar-spokesperson/compose-director.md +++ b/skills/pipelines/avatar-spokesperson/compose-director.md @@ -4,6 +4,14 @@ Render the final spokesperson outputs. The bar is simple: the presenter must look stable, speech must be clear, and subtitles or support cards must not crowd the frame. +## Runtime Routing (HARD CONSTRAINT — Remotion only) + +Phase 1 deferred from HyperFrames. `edit_decisions.render_runtime` must be `"remotion"`. This pipeline depends on the Remotion `TalkingHead` composition and `remotion_caption_burn` — both have no HyperFrames parity in Phase 1. + +- If `edit_decisions.render_runtime == "hyperframes"`, stop. Re-open the idea stage and surface the constraint. Silent rewrite is a governance violation. +- Per AGENT_GUIDE.md → "Present Both Composition Runtimes (HARD RULE)": the lock to remotion is NOT an excuse to skip the conversation. The user deserves to know that HyperFrames exists as a runtime and why it isn't viable for avatar-spokesperson. Log a `render_runtime_selection` decision with hyperframes `rejected_because: "TalkingHead + caption parity deferred on avatar-spokesperson"`. +- Pass `proposal_packet`/`brief` to `video_compose.execute()` for in-tool runtime-swap detection. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/avatar-spokesperson/idea-director.md b/skills/pipelines/avatar-spokesperson/idea-director.md index aaedb41..e1a84e8 100644 --- a/skills/pipelines/avatar-spokesperson/idea-director.md +++ b/skills/pipelines/avatar-spokesperson/idea-director.md @@ -6,6 +6,12 @@ Use this pipeline when the deliverable is a presenter-led avatar video: a spokes Your first job is to classify the avatar path honestly before anyone writes polished copy for an impossible production setup. +## Runtime Selection (MANDATORY — present the constraint, don't silently pick) + +Lock `render_runtime = "remotion"`. **HyperFrames is NOT a valid runtime on this pipeline in Phase 1** — avatar-spokesperson depends on the Remotion `TalkingHead` composition and `remotion_caption_burn`, and neither has HyperFrames parity yet. + +Per AGENT_GUIDE.md → "Present Both Composition Runtimes (HARD RULE)": do NOT silently default. Tell the user: "HyperFrames is available on your machine, but avatar-spokesperson depends on the Remotion TalkingHead composition and caption burn, so remotion is the only viable runtime here — OK to proceed?" Record a `render_runtime_selection` decision with hyperframes `rejected_because: "TalkingHead + caption parity deferred on avatar-spokesperson"`. + ## Reference Inputs - `docs/avatar-spokesperson-best-practices.md` diff --git a/skills/pipelines/cinematic/compose-director.md b/skills/pipelines/cinematic/compose-director.md index c6c346d..10049b2 100644 --- a/skills/pipelines/cinematic/compose-director.md +++ b/skills/pipelines/cinematic/compose-director.md @@ -4,6 +4,18 @@ Render the cinematic piece with careful attention to grade, audio dynamics, and frame treatment. This is not a generic export step. +## Runtime Routing (MANDATORY first step) + +Read `edit_decisions.render_runtime`. Cinematic work routes to: + +- **`render_runtime="remotion"`** — default for video-led trailers using `CinematicRenderer`. Keeps video clips, transitions, and ambient overlays in one React-based pass. +- **`render_runtime="hyperframes"`** — for kinetic title cards, HTML/GSAP-driven trailers, or launch-reel-style compositions where the visual grammar is HTML/CSS. See `skills/core/hyperframes.md`. `hyperframes lint` and `hyperframes validate` must both pass before render. +- **`render_runtime="ffmpeg"`** — simple source-footage concat with no composition. + +`delivery_promise.motion_required=true` means the locked runtime is a commitment. Silent swap to another runtime (including FFmpeg Ken Burns) is a CRITICAL governance violation. If the locked runtime fails, escalate per AGENT_GUIDE.md > "Escalate Blockers Explicitly." + +**Pass `proposal_packet` to `video_compose.execute()`** so the tool's `runtime_swap_detected` check compares directly against `proposal_packet.production_plan.render_runtime`. Without it the swap check is skipped in-tool and only the reviewer skill catches the drift. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/cinematic/proposal-director.md b/skills/pipelines/cinematic/proposal-director.md index 48d0579..9508e16 100644 --- a/skills/pipelines/cinematic/proposal-director.md +++ b/skills/pipelines/cinematic/proposal-director.md @@ -6,6 +6,31 @@ You are the **Proposal Director** for a cinematic video (trailers, brand films, **This is the approval gate.** Nothing downstream runs until the user says "go." +## Runtime Selection (required field — `render_runtime`) + +Cinematic proposals must lock **both** a `renderer_family` (creative grammar: `cinematic-trailer`, `documentary-montage`, etc.) and a `render_runtime` (technical engine). Read `skills/meta/animation-runtime-selector.md` and `skills/core/hyperframes.md` for the decision matrix, and `AGENT_GUIDE.md` → "Present Both Composition Runtimes (HARD RULE)" for the governance contract. + +**MANDATORY workflow — present both runtimes, don't silently default:** + +1. Query `video_compose.get_info()["render_engines"]`. If both `remotion` and `hyperframes` are `True`, proceed to step 2. +2. Present both runtimes to the user with brief-specific analysis: + - **Remotion** — one line on fit (mention `CinematicRenderer`, ``, existing transition stack if applicable), one line on tradeoff. + - **HyperFrames** — one line on fit (mention kinetic title sequences, registry shader transitions, or HTML-native typographic motion if applicable), one line on tradeoff. +3. Recommend one with rationale tied to the brief's `delivery_promise` (especially `motion_required`), `renderer_family`, and approved tone. +4. Wait for explicit user approval. Do NOT write `render_runtime` into `proposal_packet.production_plan` before approval. +5. Log a `render_runtime_selection` decision in `decision_log` with BOTH runtimes in `options_considered` plus `ffmpeg` if it was a realistic option. + +Fit cheat-sheet for the recommendation (NOT an auto-decision): + +- Video-led trailer with motion clips via `` + color-graded overlays → lean **Remotion**. +- HTML/GSAP-driven trailer: kinetic title sequence, launch reel, brand film where the visual grammar is typographic → lean **HyperFrames**. +- Shader transitions or registry grain overlays → lean **HyperFrames**. +- Simplest source-footage concat with no composition → **ffmpeg**. + +**Motion-required deliverables**: if `delivery_promise.motion_required=true`, the chosen runtime is a commitment. Silent downgrade to FFmpeg Ken Burns or still-led animatic is forbidden. If the chosen runtime becomes unavailable at render time, compose must escalate, not substitute. + +A `render_runtime_selection` decision with only one option considered when both were available is a CRITICAL reviewer finding. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/clip-factory/compose-director.md b/skills/pipelines/clip-factory/compose-director.md index 25deebd..2aec440 100644 --- a/skills/pipelines/clip-factory/compose-director.md +++ b/skills/pipelines/clip-factory/compose-director.md @@ -4,6 +4,14 @@ Render each clip and platform variant independently. The important behaviors here are consistency, batch resilience, and clear reporting of partial failures. +## Runtime Routing (HARD CONSTRAINT — Remotion or FFmpeg only) + +This pipeline is Phase 1 deferred from the HyperFrames adoption schedule. `edit_decisions.render_runtime` must be `"remotion"` (default) or `"ffmpeg"` (pure-concat clip jobs with no composition). HyperFrames is NOT a valid runtime here — clip-factory depends on Remotion word-level caption burn, and HyperFrames caption parity is deferred work. + +- If `edit_decisions.render_runtime == "hyperframes"`, stop. Re-open the idea stage so the user can be presented the real constraint and lock `remotion` with a `render_runtime_selection` decision that records `hyperframes` as `rejected_because: "caption-burn parity deferred on clip-factory"`. +- Per AGENT_GUIDE.md → "Present Both Composition Runtimes (HARD RULE)": the constraint is NOT an excuse to skip the conversation. The user still gets to see that HyperFrames exists and why it isn't viable here. +- Pass `proposal_packet`/`brief` to `video_compose.execute()` so the in-tool runtime-swap check runs end-to-end. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/clip-factory/idea-director.md b/skills/pipelines/clip-factory/idea-director.md index aa4644a..ea0fe71 100644 --- a/skills/pipelines/clip-factory/idea-director.md +++ b/skills/pipelines/clip-factory/idea-director.md @@ -6,6 +6,12 @@ Use this pipeline when the source is long-form footage and the goal is multiple You are not planning one video. You are planning a ranked portfolio of clips. +## Runtime Selection (MANDATORY — present the constraint, don't silently pick) + +Lock `render_runtime = "remotion"` (for composed clips with word-level captions) or `"ffmpeg"` (for pure concat/trim with no composition). **HyperFrames is NOT a valid runtime on this pipeline in Phase 1** — clip-factory depends on Remotion's word-level caption burn, which has no HyperFrames parity yet. + +Per AGENT_GUIDE.md → "Present Both Composition Runtimes (HARD RULE)": do NOT silently lock remotion. Surface the constraint to the user: "HyperFrames is an available runtime on your machine, but clip-factory depends on Remotion caption burn that doesn't have HyperFrames parity yet, so remotion is the only viable choice here — OK to proceed?" Record the decision in `decision_log` with category `render_runtime_selection`, including hyperframes as a rejected option (`rejected_because: "caption-burn parity deferred on clip-factory"`). + ## Reference Inputs - `docs/clip-factory-best-practices.md` diff --git a/skills/pipelines/documentary-montage/compose-director.md b/skills/pipelines/documentary-montage/compose-director.md index 19c1243..a0576a5 100644 --- a/skills/pipelines/documentary-montage/compose-director.md +++ b/skills/pipelines/documentary-montage/compose-director.md @@ -9,6 +9,14 @@ mix) that makes a mixed-era corpus feel like one film. The output is a single mp4 plus a `render_report` artifact. +## Runtime Routing (HARD CONSTRAINT) + +This pipeline currently REQUIRES `render_runtime="remotion"`. The end-tag stack (ProRes 4444 overlay composited on final scenes, or concat fallback) depends on Remotion's `CinematicRenderer` composition and its alpha-preserving render path. HyperFrames end-tag parity is explicitly Wave 3 / deferred work (see `skills/core/hyperframes.md` → "What stays Remotion-only in Phase 1"). + +- If `edit_decisions.render_runtime` is anything other than `remotion`, stop. This is a CRITICAL governance violation. Surface the conflict to the user, route the decision back to proposal to re-lock `render_runtime="remotion"`, log a `render_runtime_selection` correction in decision_log, and resume. +- Never silently proceed by rewriting render_runtime in edit_decisions. The documentary promise (motion-led, mood-driven, uniform grade) is preserved by the Remotion stack, and that promise is what the user approved. +- Pass `proposal_packet` to `video_compose.execute()` so the in-tool `runtime_swap_detected` check actively confirms the runtime stayed `remotion` end-to-end. A `skipped` check on this pipeline means you forgot to pass the proposal artifact. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/documentary-montage/idea-director.md b/skills/pipelines/documentary-montage/idea-director.md index ca7af90..6b07657 100644 --- a/skills/pipelines/documentary-montage/idea-director.md +++ b/skills/pipelines/documentary-montage/idea-director.md @@ -7,6 +7,12 @@ downstream stage will read. For this pipeline, the brief is the thematic core: what the montage is ABOUT, what it should feel like, and how long it should run. +## Runtime Selection (MANDATORY — present the constraint, don't silently pick) + +Lock `render_runtime = "remotion"`. **HyperFrames is NOT a valid runtime on this pipeline in Phase 1** — documentary-montage depends on the Remotion `CinematicRenderer` composition and its ProRes-4444 alpha end-tag overlay stack, neither of which has HyperFrames parity. + +Per AGENT_GUIDE.md → "Present Both Composition Runtimes (HARD RULE)": do NOT silently default. Tell the user: "HyperFrames is available on your machine as an alternative runtime, but documentary-montage depends on the Remotion CinematicRenderer + end-tag overlay stack, so remotion is the only viable choice here — OK to proceed?" Record a `render_runtime_selection` decision in `decision_log` listing both runtimes in `options_considered`, with hyperframes `rejected_because: "CinematicRenderer + end-tag overlay parity deferred on documentary-montage"`. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/explainer/compose-director.md b/skills/pipelines/explainer/compose-director.md index e3cadf6..293d202 100644 --- a/skills/pipelines/explainer/compose-director.md +++ b/skills/pipelines/explainer/compose-director.md @@ -6,6 +6,18 @@ You are the Compositor for a generated explainer video. You have `edit_decisions This is the last technical stage before the video exists as a playable file. Everything converges here. +## Runtime Routing (MANDATORY first step) + +Read `edit_decisions.render_runtime` before anything else. It was locked at proposal and must not be changed silently. The rest of this skill's process steps (Remotion public/ staging, word-level caption burn, etc.) assume `render_runtime="remotion"` — the default for data-driven explainers. + +- **`render_runtime="hyperframes"`** — HTML/CSS/GSAP render. Do NOT follow the Remotion-specific steps below. Instead: read `skills/core/hyperframes.md`, `.agents/skills/hyperframes/SKILL.md`, and `.agents/skills/hyperframes-cli/SKILL.md`. Call `video_compose` with the edit_decisions unchanged — it will delegate to `hyperframes_compose`, which materializes a workspace under `projects//hyperframes/`, runs `lint → validate → render`, and returns the MP4. Both lint AND validate must pass before render; contrast can be deferred during iteration but not for final delivery. +- **`render_runtime="ffmpeg"`** — simple concat/trim. Call `video_compose` directly; it will NOT auto-upgrade to Remotion when this runtime is explicitly locked. +- **Runtime unavailable** — surface the blocker per AGENT_GUIDE.md > "Escalate Blockers Explicitly" and get user approval (recorded as a `render_runtime_selection` decision in decision_log) before switching. + +`final_review.checks.promise_preservation.render_runtime_used` must equal the runtime that actually ran; `runtime_swap_detected` must be `false` unless an approved decision authorizes the swap. + +**Pass `proposal_packet` to `video_compose.execute()`** so in-tool swap detection can actually fire. Without it the `runtime_swap_check` is reported as `skipped` and you have to rely on the reviewer skill's cross-artifact comparison instead. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/explainer/proposal-director.md b/skills/pipelines/explainer/proposal-director.md index 7023b73..526cc61 100644 --- a/skills/pipelines/explainer/proposal-director.md +++ b/skills/pipelines/explainer/proposal-director.md @@ -8,6 +8,28 @@ You are the **Proposal Director** for a generated explainer video. You sit betwe Think of yourself as a creative agency pitching to a client: you present concepts backed by research, show what it'll cost, explain the tradeoffs, and let the client choose. +## Runtime Selection (required field — `render_runtime`) + +Explainer proposals must lock **both** a `renderer_family` (creative grammar) and a `render_runtime` (technical engine). Read `skills/meta/animation-runtime-selector.md` for the decision matrix and `AGENT_GUIDE.md` → "Present Both Composition Runtimes (HARD RULE)" for the governance contract. + +**MANDATORY workflow — present both runtimes, don't silently default:** + +1. Query `video_compose.get_info()["render_engines"]`. If both `remotion` and `hyperframes` are `True`, proceed to step 2. If only one is available, go to step 4 with just that one. +2. Present both runtimes to the user with brief-specific analysis. For THIS concept: + - **Remotion** — one line on fit (mention the React scene stack components that apply), one line on tradeoff. + - **HyperFrames** — one line on fit (mention HTML/GSAP motion, registry blocks, kinetic typography if applicable), one line on tradeoff. +3. Recommend one with rationale tied to the brief's `delivery_promise`, `visual_approach`, and whether word-level caption burn is required (that one forces Remotion). +4. Wait for explicit user approval. Do NOT write `render_runtime` into `proposal_packet.production_plan` before approval. +5. Log a `render_runtime_selection` decision in `decision_log` with BOTH runtimes (plus `ffmpeg` if it was a realistic option) in `options_considered`, the user's pick as `selected`, and the rationale as `reason`. If a runtime was unavailable, record it as rejected with `rejected_because: "runtime not available on this machine"`. + +Fit cheat-sheet for recommendation (input for the conversation, not an auto-decision): + +- Existing React scene stack (text_card, stat_card, bar_chart, line_chart, pie_chart, kpi_grid, callout, comparison, hero_title, caption overlay, anime_scene) fits → recommend **Remotion**. +- Kinetic typography, custom HTML motion graphics, registry-block-driven scenes, or website-to-video → recommend **HyperFrames**. +- Word-level/karaoke captions required → **Remotion only** in Phase 1 (caption parity is deferred). + +A `render_runtime_selection` decision with only one option considered when both were available is a CRITICAL reviewer finding. + ## Prerequisites | Layer | Resource | Purpose | diff --git a/skills/pipelines/hybrid/compose-director.md b/skills/pipelines/hybrid/compose-director.md index ea76137..cdef484 100644 --- a/skills/pipelines/hybrid/compose-director.md +++ b/skills/pipelines/hybrid/compose-director.md @@ -4,6 +4,18 @@ Render the hybrid project so source media, support graphics, and audio all remain coherent across outputs. +## Runtime Routing (MANDATORY first step) + +Read `edit_decisions.render_runtime`. Hybrid work typically sticks with Remotion because source footage + React support overlays compose cleanly in one pass: + +- **`render_runtime="remotion"`** — default. Source footage via ``, support graphics as React components, one render. +- **`render_runtime="hyperframes"`** — pick only when the support layer is HTML/GSAP-native (e.g., animated text callouts, registry blocks). Source footage is still possible via `

/artifacts/, + # so the workspace is at projects/

/hyperframes/. + candidate = comp_path + resolved = None + for _ in range(5): + candidate = candidate.parent + hf_assets = candidate / "hyperframes" / "assets" + if hf_assets.is_dir(): + resolved = hf_assets + break + local_assets = candidate / "assets" + if local_assets.is_dir() and (candidate / "index.html").is_file(): + resolved = local_assets + break + assets_root = resolved or comp_path.parent + elif runtime == "ffmpeg": + # FFmpeg jobs reference files by absolute path; fall back to + # the composition's parent for any bare-name references. assets_root = comp_path.parent + else: + # Remotion (default): remotion-composer/public + candidate = comp_path + resolved = None + for _ in range(5): + candidate = candidate.parent + public = candidate / "remotion-composer" / "public" + if public.is_dir(): + resolved = public + break + assets_root = resolved or comp_path.parent errors: list[str] = [] warnings: list[str] = [] @@ -125,6 +168,7 @@ class CompositionValidator(BaseTool): if out_s > video_duration: video_duration = out_s info.append(f"Video duration: {video_duration}s ({len(cuts)} cuts)") + info.append(f"Render runtime: {runtime or 'default (remotion)'}; assets root: {assets_root}") # --- Check 3: Cut ordering and gaps --- sorted_cuts = sorted(cuts, key=lambda c: c.get("in_seconds", 0)) diff --git a/tools/graphics/image_gen.py b/tools/graphics/image_gen.py index 0eae1b3..933e092 100644 --- a/tools/graphics/image_gen.py +++ b/tools/graphics/image_gen.py @@ -60,6 +60,16 @@ class ImageGen(BaseTool): "generate_diagram_overlay", "generate_illustration", ] + best_for = [ + "DEPRECATED — prefer image_selector which routes to per-provider tools " + "(flux_image, openai_image, recraft_image, grok_image, local_diffusion, " + "pexels_image, pixabay_image).", + "Kept only for backwards compatibility. New code should not call this.", + ] + not_good_for = [ + "New production code — use image_selector instead.", + "Picking a specific provider (use the per-provider tool directly).", + ] input_schema = { "type": "object", diff --git a/tools/tool_registry.py b/tools/tool_registry.py index 9202901..4c61d68 100644 --- a/tools/tool_registry.py +++ b/tools/tool_registry.py @@ -15,6 +15,43 @@ from typing import Any, Optional from tools.base_tool import BaseTool, ToolStatus, ToolTier, ToolStability +# Unicode punctuation that breaks on Windows cp1252 stdout. Map each to an +# ASCII equivalent. This only touches strings rendered by registry helpers +# that an agent is likely to print to the user at preflight — not docstrings, +# comments, or markdown. +_UNICODE_DASH_REPLACEMENTS = { + "\u2014": "--", # em dash + "\u2013": "-", # en dash + "\u2212": "-", # minus sign + "\u2018": "'", # left single quote + "\u2019": "'", # right single quote + "\u201c": '"', # left double quote + "\u201d": '"', # right double quote + "\u2026": "...", # ellipsis +} + + +def _scrub_unicode_dashes(value: Any) -> Any: + """Recursively normalize unicode punctuation in str leaves to ASCII. + + Used to keep `provider_menu_summary()` output readable on Windows cp1252 + stdout. Does NOT modify dict/list structure or non-string values. + """ + if isinstance(value, str): + out = value + for needle, repl in _UNICODE_DASH_REPLACEMENTS.items(): + if needle in out: + out = out.replace(needle, repl) + return out + if isinstance(value, list): + return [_scrub_unicode_dashes(item) for item in value] + if isinstance(value, tuple): + return tuple(_scrub_unicode_dashes(item) for item in value) + if isinstance(value, dict): + return {k: _scrub_unicode_dashes(v) for k, v in value.items()} + return value + + class ToolRegistry: """Central registry of all OpenMontage tools.""" @@ -258,6 +295,125 @@ class ToolRegistry: return dict(sorted(menu.items())) + def provider_menu_summary(self) -> dict[str, Any]: + """Compact, human-ready rollup of provider_menu() for onboarding/preflight. + + Returns a dict shaped for the "N of M configured" capability menu the + agent is supposed to present to the user per AGENT_GUIDE.md → "Provider + Menu (Mandatory at Preflight)". Collapses the firehose of + support_envelope() into something the agent can paraphrase in plain + language in a few lines. + + Example output (abbreviated): + { + "composition_runtimes": { + "ffmpeg": True, + "remotion": True, + "hyperframes": True, + }, + "capabilities": [ + {"capability": "video_generation", "configured": 10, "total": 16, + "available_providers": ["fal", "heygen", ...], + "unavailable_providers": ["openai", ...]}, + ... + ], + "setup_offers": [ + {"capability": "music_generation", "tool": "suno_music", + "install_instructions": "Add SUNO_API_KEY to .env"}, + ... + ], + "runtime_warnings": [ + "hyperframes: npm package `hyperframes` not resolvable: ...", + ... + ], + } + + Agents should use this as the source for the preflight capability + menu rather than rendering `support_envelope()` or `provider_menu()` + raw. See AGENT_GUIDE.md > "Provider Menu (Mandatory at Preflight)". + """ + self.ensure_discovered() + menu = self.provider_menu() + + # Composition runtimes — lift from video_compose.get_info() since + # they're the signal the runtime-selection contract depends on. + comp_runtimes: dict[str, bool] = {} + runtime_warnings: list[str] = [] + vc = self._tools.get("video_compose") + if vc is not None: + info = vc.get_info() + engines = info.get("render_engines") or {} + comp_runtimes = {k: bool(v) for k, v in engines.items()} + # If hyperframes_compose is registered, surface its npm-resolve reasons + # explicitly — those are the "looks available but isn't" failures. + hf = self._tools.get("hyperframes_compose") + if hf is not None: + hf_info = hf.get_info() + rc = hf_info.get("hyperframes_runtime") or {} + for reason in rc.get("reasons") or []: + runtime_warnings.append(f"hyperframes: {reason}") + + # Capabilities rollup (configured/total + provider lists). + # When a provider has multiple tools (e.g. seedance-fal and + # seedance-replicate both reporting provider="seedance"), a + # naive set-split shows the provider in BOTH available and + # unavailable — confusing for users. Dedupe: if the provider has + # any available tool, do NOT list it as unavailable. + capabilities: list[dict[str, Any]] = [] + for cap, bucket in menu.items(): + available_providers = { + e.get("provider") for e in bucket.get("available", []) + } - {None} + unavailable_providers = ( + {e.get("provider") for e in bucket.get("unavailable", [])} + - {None} + - available_providers # provider with any available tool wins + ) + capabilities.append( + { + "capability": cap, + "configured": bucket.get("configured", 0), + "total": bucket.get("total", 0), + "available_providers": sorted(available_providers), + "unavailable_providers": sorted(unavailable_providers), + } + ) + + # Setup offers — unavailable tools that would be 1-minute env-var fixes. + # Filter for short install instructions referencing an env var so the + # agent can lead with the easy wins. + setup_offers: list[dict[str, Any]] = [] + for cap, bucket in menu.items(): + for entry in bucket.get("unavailable", []): + hint = entry.get("install_instructions") or "" + # Heuristic: 1-minute fixes mention an env var or API key. + if any(k in hint.lower() for k in ["api key", "env", "_key=", "_api"]): + setup_offers.append( + { + "capability": cap, + "tool": entry.get("name"), + "provider": entry.get("provider"), + "install_instructions": hint, + } + ) + + result = { + "composition_runtimes": comp_runtimes, + "capabilities": capabilities, + "setup_offers": setup_offers, + "runtime_warnings": runtime_warnings, + } + # Normalize em-dashes and en-dashes to ASCII so preflight output prints + # cleanly on Windows cp1252 stdout (the default on Git Bash / PowerShell + # without PYTHONIOENCODING=utf-8). Agents paste this dict into chat; a + # mojibake `�` in an install_instructions string looks like a bug. + # Markdown docs keep their typographic dashes; this only touches the + # runtime-reported strings. + return _scrub_unicode_dashes(result) + + # Post-hoc fix: narrow helper that keeps the registry output stdout-safe on + # Windows cp1252 without imposing a new style rule on every tool author. + def gpu_required_tools(self) -> list[str]: """List tools that require GPU (VRAM > 0).""" return [ diff --git a/tools/video/hyperframes_compose.py b/tools/video/hyperframes_compose.py new file mode 100644 index 0000000..33d338a --- /dev/null +++ b/tools/video/hyperframes_compose.py @@ -0,0 +1,1186 @@ +"""HyperFrames composition tool — HTML/CSS/GSAP render path. + +Sibling to `video_compose` (FFmpeg + Remotion). This tool owns the HyperFrames +runtime end-to-end: workspace materialization, `hyperframes lint`, +`hyperframes validate`, and `hyperframes render`. It is invoked by +`video_compose` when `edit_decisions.render_runtime == "hyperframes"`, and +can also be called directly by pipelines that want HyperFrames-specific +operations (lint-only, validate-only, scaffold-only). + +This tool deliberately does NOT attempt parity with every Remotion scene +component. See `skills/core/hyperframes.md` for what is in scope in Phase 1 +and what remains Remotion-only. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any, Optional + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + ResumeSupport, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +log = logging.getLogger("hyperframes_compose") + + +_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp", ".gif"} +_VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm", ".mkv", ".m4v"} +_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"} + + +class HyperFramesCompose(BaseTool): + name = "hyperframes_compose" + version = "0.1.0" + tier = ToolTier.CORE + capability = "video_post" + provider = "hyperframes" + stability = ToolStability.BETA + execution_mode = ExecutionMode.SYNC + determinism = Determinism.DETERMINISTIC + runtime = ToolRuntime.LOCAL + + dependencies = ["cmd:npx", "cmd:ffmpeg"] + install_instructions = ( + "Requires Node.js >= 22 (https://nodejs.org/) and FFmpeg " + "(https://ffmpeg.org/download.html). The HyperFrames CLI is fetched " + "on first use via `npx hyperframes` (npm package: `hyperframes`). " + "Note: the upstream monorepo develops the package as `@hyperframes/cli`, " + "but it publishes to npm as `hyperframes`. `npx @hyperframes/cli` " + "returns 404 -- do NOT use that form. Verify setup with " + "`npx hyperframes doctor` or run the `doctor` operation on this tool." + ) + agent_skills = [ + "hyperframes", + "hyperframes-cli", + "hyperframes-registry", + "website-to-hyperframes", + "gsap-core", + "gsap-timeline", + ] + + capabilities = [ + "hyperframes_render", + "hyperframes_lint", + "hyperframes_validate", + "hyperframes_doctor", + "scaffold_workspace", + "add_block", + ] + + best_for = [ + "HTML/CSS/GSAP composition: kinetic typography, product promos, launch reels", + "Motion-graphics-heavy briefs where the scene library in remotion-composer/ doesn't fit", + "Website-to-video / UI-driven compositions", + "Registry-block-driven scenes (hyperframes add data-chart, grain-overlay, etc.)", + ] + not_good_for = [ + "Word-level caption burn (stays on Remotion in Phase 1)", + "Avatar / lip-sync presenter (stays on Remotion in Phase 1)", + "Existing React scene stack (text_card, stat_card, chart, comparison): reuse Remotion", + ] + fallback_tools = ["video_compose"] + + input_schema = { + "type": "object", + "required": ["operation"], + "properties": { + "operation": { + "type": "string", + "enum": [ + "render", + "lint", + "validate", + "doctor", + "scaffold_workspace", + "add_block", + ], + "description": ( + "render: materialize workspace + lint + validate + render to MP4. " + "lint: run `hyperframes lint` on an existing workspace. " + "validate: run `hyperframes validate` (browser-based). " + "doctor: run `hyperframes doctor` to check environment. " + "scaffold_workspace: materialize HTML/CSS/assets but do not render. " + "add_block: run `hyperframes add ` to install a registry " + "block or component into an existing workspace." + ), + }, + "block_name": { + "type": "string", + "description": ( + "Registry block or component name for operation='add_block' " + "(e.g. 'data-chart', 'grain-overlay', 'shimmer-sweep'). " + "See https://hyperframes.heygen.com/catalog for the list." + ), + }, + "workspace_path": { + "type": "string", + "description": ( + "Target HyperFrames workspace directory. Typically " + "`projects//hyperframes/`. Required for every op " + "except doctor." + ), + }, + "output_path": { + "type": "string", + "description": "Output MP4 path. Used by operation='render'.", + }, + "edit_decisions": { + "type": "object", + "description": ( + "Full edit_decisions artifact — required for render and " + "scaffold_workspace. Used to generate index.html + CSS." + ), + }, + "asset_manifest": { + "type": "object", + "description": ( + "Full asset_manifest artifact — required for render and " + "scaffold_workspace. Used to resolve asset IDs to file paths." + ), + }, + "playbook": { + "type": "object", + "description": ( + "Loaded playbook dict. Used to drive the style bridge " + "(CSS custom properties, typography, motion defaults)." + ), + }, + "profile": { + "type": "string", + "description": "Media profile name (youtube_landscape, tiktok_vertical, etc.).", + }, + "quality": { + "type": "string", + "enum": ["draft", "standard", "high"], + "default": "standard", + "description": "Render quality. `draft` for iterating, `high` for delivery.", + }, + "fps": { + "type": "integer", + "enum": [24, 30, 60], + "default": 30, + }, + "strict": { + "type": "boolean", + "default": False, + "description": ( + "If true, fail the render on any lint error. Matches " + "`hyperframes render --strict`." + ), + }, + "skip_contrast": { + "type": "boolean", + "default": False, + "description": ( + "Skip the WCAG contrast audit during validate. Acceptable " + "while iterating; forbidden for final delivery." + ), + }, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=4, ram_mb=3072, vram_mb=0, disk_mb=2000, network_required=False + ) + retry_policy = RetryPolicy(max_retries=0) + resume_support = ResumeSupport.FROM_START + idempotency_key_fields = ["operation", "workspace_path", "edit_decisions"] + side_effects = [ + "writes HTML/CSS/JS files into workspace_path", + "copies asset files into workspace_path/assets/", + "writes MP4 to output_path", + ] + user_visible_verification = [ + "Play the rendered MP4 and verify scene pacing, typography, and audio", + "Inspect workspace_path/index.html in a browser via `npx hyperframes preview`", + ] + + # ------------------------------------------------------------------ + # Status / availability + # ------------------------------------------------------------------ + + _NODE_FLOOR_MAJOR = 22 + _NPM_PACKAGE = "hyperframes" # published npm name (NOT @hyperframes/cli — that's 404) + # Process-level cache for the npm resolve check. Shape: + # {"version": "0.4.5"} → package resolves + # {"error": ""} → resolution failed (offline, unpublished, etc.) + # We cache per-process so the first call pays ~2-5s and subsequent calls + # (get_info spam from the registry) are free. + _npm_resolve_cache: Optional[dict[str, str]] = None + + @classmethod + def _node_major_version(cls) -> Optional[int]: + """Return Node.js major version, or None if node isn't installed.""" + node = shutil.which("node") + if not node: + return None + try: + out = subprocess.run( + [node, "--version"], capture_output=True, text=True, timeout=5 + ) + if out.returncode != 0: + return None + match = re.match(r"v?(\d+)\.", out.stdout.strip()) + if not match: + return None + return int(match.group(1)) + except (OSError, subprocess.SubprocessError): + return None + + @classmethod + def _resolve_npm_package(cls) -> dict[str, str]: + """Verify the `hyperframes` npm package actually resolves. + + `_runtime_check` previously only verified that node/ffmpeg/npx existed + on PATH, which meant `runtime_available: True` on any machine with + Node + FFmpeg — even offline, even if npm was down, even if the + package was unpublished. This method performs a cheap + `npm view hyperframes version` (5s timeout) and caches the answer + for the rest of the process. + + Returns {"version": "X.Y.Z"} on success, {"error": ""} on any + failure (404, timeout, network error, npm missing). Never raises. + """ + if cls._npm_resolve_cache is not None: + return cls._npm_resolve_cache + + npm = shutil.which("npm") + if not npm: + cls._npm_resolve_cache = {"error": "npm not on PATH"} + return cls._npm_resolve_cache + + try: + proc = subprocess.run( + [npm, "view", cls._NPM_PACKAGE, "version"], + capture_output=True, + text=True, + timeout=5, + ) + except subprocess.TimeoutExpired: + cls._npm_resolve_cache = {"error": "timeout (5s) — offline or slow registry"} + return cls._npm_resolve_cache + except (OSError, subprocess.SubprocessError) as e: + cls._npm_resolve_cache = {"error": f"npm view failed: {type(e).__name__}"} + return cls._npm_resolve_cache + + if proc.returncode != 0: + stderr = (proc.stderr or "").strip() + # Most common failure is 404 (package unpublished or name wrong). + if "404" in stderr or "E404" in stderr: + cls._npm_resolve_cache = { + "error": f"npm package `{cls._NPM_PACKAGE}` not found (404)" + } + else: + tail = stderr.splitlines()[-1][:200] if stderr else f"exit {proc.returncode}" + cls._npm_resolve_cache = {"error": f"npm view failed: {tail}"} + return cls._npm_resolve_cache + + version = (proc.stdout or "").strip() + if not version: + cls._npm_resolve_cache = {"error": "npm view returned empty version"} + else: + cls._npm_resolve_cache = {"version": version} + return cls._npm_resolve_cache + + def _runtime_check(self) -> dict[str, Any]: + """Return availability state for the HyperFrames runtime. + + Checks BOTH local binaries (node >= 22, ffmpeg, npx) AND that the + `hyperframes` npm package actually resolves. A missing/404 package + counts as unavailable — `runtime_available: True` means the runtime + can genuinely run end-to-end, not just that the local tooling exists. + """ + node_major = self._node_major_version() + ffmpeg_ok = shutil.which("ffmpeg") is not None + npx_ok = shutil.which("npx") is not None + + reasons: list[str] = [] + if node_major is None: + reasons.append("node not found on PATH") + elif node_major < self._NODE_FLOOR_MAJOR: + reasons.append( + f"node major version {node_major} < required {self._NODE_FLOOR_MAJOR}" + ) + if not npx_ok: + reasons.append("npx not found on PATH") + if not ffmpeg_ok: + reasons.append("ffmpeg not found on PATH") + + # Only probe npm if the local tooling is actually usable — otherwise + # a missing-node run would also show a confusing npm error. + npm_resolve: dict[str, str] = {} + if not reasons: + npm_resolve = self._resolve_npm_package() + if "error" in npm_resolve: + reasons.append( + f"npm package `{self._NPM_PACKAGE}` not resolvable: " + f"{npm_resolve['error']}" + ) + + return { + "runtime_available": not reasons, + "node_major": node_major, + "ffmpeg_available": ffmpeg_ok, + "npx_available": npx_ok, + "npm_package": self._NPM_PACKAGE, + "npm_package_version": npm_resolve.get("version"), + "npm_resolve_error": npm_resolve.get("error"), + "reasons": reasons, + } + + def get_status(self) -> ToolStatus: + check = self._runtime_check() + return ToolStatus.AVAILABLE if check["runtime_available"] else ToolStatus.UNAVAILABLE + + def get_info(self) -> dict[str, Any]: + info = super().get_info() + check = self._runtime_check() + info["hyperframes_runtime"] = check + if not check["runtime_available"]: + info["setup_offer"] = { + "effort": ( + "1-minute fix" + if check["npx_available"] and check["ffmpeg_available"] + else "5-minute fix (install Node 22+ and/or FFmpeg)" + ), + "install_instructions": self.install_instructions, + "unlocks": ( + "HTML/CSS/GSAP composition runtime — kinetic typography, " + "product promos, registry blocks, website-to-video." + ), + } + return info + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + return 0.0 + + def estimate_runtime(self, inputs: dict[str, Any]) -> float: + ed = inputs.get("edit_decisions") or {} + cuts = ed.get("cuts", []) + total = 0.0 + for c in cuts: + out_s = float(c.get("out_seconds", 0) or 0) + in_s = float(c.get("in_seconds", 0) or 0) + total += max(0.0, out_s - in_s) + return 30.0 + total * 0.5 + + # ------------------------------------------------------------------ + # Execute + # ------------------------------------------------------------------ + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + operation = inputs["operation"] + start = time.time() + try: + if operation == "doctor": + result = self._doctor(inputs) + elif operation == "scaffold_workspace": + result = self._scaffold(inputs) + elif operation == "lint": + result = self._lint(inputs) + elif operation == "validate": + result = self._validate(inputs) + elif operation == "render": + result = self._render(inputs) + elif operation == "add_block": + result = self._add_block(inputs) + else: + return ToolResult(success=False, error=f"Unknown operation: {operation}") + except Exception as e: + log.exception("hyperframes_compose failed") + return ToolResult(success=False, error=f"{type(e).__name__}: {e}") + + result.duration_seconds = round(time.time() - start, 2) + return result + + # ------------------------------------------------------------------ + # Operations + # ------------------------------------------------------------------ + + def _doctor(self, inputs: dict[str, Any]) -> ToolResult: + """Probe the environment. Reports node/ffmpeg/npx plus CLI doctor output.""" + check = self._runtime_check() + out: dict[str, Any] = {"runtime_check": check} + + if not check["runtime_available"]: + return ToolResult( + success=False, + error=( + "HyperFrames runtime floor not met: " + + "; ".join(check["reasons"]) + ), + data=out, + ) + + # Ask the CLI itself for a deeper check. This also warms the npm + # cache so the first real render doesn't pay the download cost. + try: + proc = self._run_hf(["doctor"], cwd=None, timeout=180, check=False) + out["cli_doctor"] = { + "exit_code": proc.returncode, + "stdout_tail": (proc.stdout or "")[-4000:], + "stderr_tail": (proc.stderr or "")[-4000:], + } + ok = proc.returncode == 0 + return ToolResult( + success=ok, + data=out, + error=None if ok else f"hyperframes doctor exit {proc.returncode}", + ) + except Exception as e: + out["cli_doctor_error"] = str(e) + return ToolResult( + success=False, + error=f"hyperframes doctor failed: {e}", + data=out, + ) + + def _scaffold(self, inputs: dict[str, Any]) -> ToolResult: + """Materialize the HyperFrames workspace from OpenMontage artifacts. + + This does NOT call `hyperframes init` — we want full control over the + generated files so they map cleanly to edit_decisions. `init` is + meant for humans bootstrapping a project by hand. + """ + workspace = self._require_workspace(inputs) + edit_decisions = inputs.get("edit_decisions") or {} + asset_manifest = inputs.get("asset_manifest") or {} + playbook = inputs.get("playbook") or {} + profile_name = inputs.get("profile") + + if not edit_decisions.get("cuts"): + return ToolResult( + success=False, + error="edit_decisions with non-empty cuts[] is required for scaffold_workspace", + ) + + width, height, fps = self._resolve_dimensions(profile_name, inputs.get("fps", 30)) + + workspace.mkdir(parents=True, exist_ok=True) + (workspace / "compositions").mkdir(exist_ok=True) + assets_dir = workspace / "assets" + assets_dir.mkdir(exist_ok=True) + + # Resolve asset IDs → file paths + copy into workspace. + resolved_cuts, asset_copies = self._resolve_and_stage_assets( + edit_decisions.get("cuts", []), + asset_manifest.get("assets", []), + workspace, + ) + + audio_refs = self._resolve_audio_refs( + edit_decisions.get("audio", {}), + asset_manifest.get("assets", []), + workspace, + ) + + # Style bridge: playbook → CSS custom properties + DESIGN.md. + css_vars, design_md = self._style_bridge(playbook, edit_decisions) + + # Write hyperframes.json (registry config). + (workspace / "hyperframes.json").write_text( + json.dumps( + { + "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry", + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets", + }, + }, + indent=2, + ), + encoding="utf-8", + ) + + # Write DESIGN.md (convenience file for human review + workspace context). + if design_md: + (workspace / "DESIGN.md").write_text(design_md, encoding="utf-8") + + # Write index.html — the main composition. + total_duration = self._compute_total_duration(resolved_cuts) + html = self._generate_index_html( + cuts=resolved_cuts, + audio_refs=audio_refs, + width=width, + height=height, + total_duration=total_duration, + css_vars=css_vars, + title=edit_decisions.get("metadata", {}).get("title") + or f"OpenMontage {edit_decisions.get('renderer_family', 'composition')}", + ) + (workspace / "index.html").write_text(html, encoding="utf-8") + + return ToolResult( + success=True, + data={ + "operation": "scaffold_workspace", + "workspace": str(workspace), + "width": width, + "height": height, + "fps": fps, + "total_duration_seconds": total_duration, + "cut_count": len(resolved_cuts), + "asset_copies": asset_copies, + }, + artifacts=[str(workspace / "index.html")], + ) + + def _lint(self, inputs: dict[str, Any]) -> ToolResult: + workspace = self._require_workspace(inputs) + if not (workspace / "index.html").exists(): + return ToolResult( + success=False, + error=f"No index.html in {workspace}. Run scaffold_workspace first.", + ) + proc = self._run_hf(["lint", "--json"], cwd=workspace, timeout=120, check=False) + data: dict[str, Any] = {"exit_code": proc.returncode} + payload = self._parse_json_output(proc.stdout) + if payload is not None: + data["report"] = payload + else: + data["stdout_tail"] = (proc.stdout or "")[-4000:] + data["stderr_tail"] = (proc.stderr or "")[-2000:] + ok = proc.returncode == 0 + return ToolResult( + success=ok, + data=data, + error=None if ok else f"hyperframes lint exit {proc.returncode}", + ) + + def _validate(self, inputs: dict[str, Any]) -> ToolResult: + workspace = self._require_workspace(inputs) + if not (workspace / "index.html").exists(): + return ToolResult( + success=False, + error=f"No index.html in {workspace}. Run scaffold_workspace first.", + ) + args = ["validate", "--json"] + if inputs.get("skip_contrast"): + args.append("--no-contrast") + proc = self._run_hf(args, cwd=workspace, timeout=300, check=False) + data: dict[str, Any] = {"exit_code": proc.returncode} + payload = self._parse_json_output(proc.stdout) + if payload is not None: + data["report"] = payload + else: + data["stdout_tail"] = (proc.stdout or "")[-4000:] + data["stderr_tail"] = (proc.stderr or "")[-2000:] + ok = proc.returncode == 0 + return ToolResult( + success=ok, + data=data, + error=None if ok else f"hyperframes validate exit {proc.returncode}", + ) + + def _add_block(self, inputs: dict[str, Any]) -> ToolResult: + """Install a registry block or component via `hyperframes add`. + + Blocks are standalone sub-compositions (own dimensions, duration, timeline) + that land at `compositions/.html`. Components are effect snippets + that land at `compositions/components/.html`. After install, the + caller is responsible for wiring the block into `index.html` via + `data-composition-src` or pasting the component's snippet — see + `.agents/skills/hyperframes-registry/SKILL.md`. + """ + workspace = self._require_workspace(inputs) + block = (inputs.get("block_name") or "").strip() + if not block: + return ToolResult( + success=False, + error="block_name is required for operation='add_block'", + ) + if not workspace.exists(): + return ToolResult( + success=False, + error=( + f"Workspace {workspace} does not exist. Run " + "operation='scaffold_workspace' first." + ), + ) + args = ["add", block, "--json", "--no-clipboard"] + proc = self._run_hf(args, cwd=workspace, timeout=300, check=False) + data: dict[str, Any] = { + "operation": "add_block", + "block_name": block, + "workspace": str(workspace), + "exit_code": proc.returncode, + } + payload = self._parse_json_output(proc.stdout) + if payload is not None: + data["report"] = payload + else: + data["stdout_tail"] = (proc.stdout or "")[-4000:] + data["stderr_tail"] = (proc.stderr or "")[-2000:] + ok = proc.returncode == 0 + return ToolResult( + success=ok, + data=data, + error=None if ok else f"hyperframes add {block} exit {proc.returncode}", + ) + + def _render(self, inputs: dict[str, Any]) -> ToolResult: + """Full pipeline: scaffold → lint → validate → render.""" + runtime_ok = self._runtime_check() + if not runtime_ok["runtime_available"]: + return ToolResult( + success=False, + error=( + "HyperFrames runtime not available: " + + "; ".join(runtime_ok["reasons"]) + + ". Per governance, this is a blocker — do NOT silently " + "fall back to another runtime without user approval." + ), + data={"runtime_check": runtime_ok}, + ) + + workspace = self._require_workspace(inputs) + output_path = Path(inputs.get("output_path") or (workspace / "renders" / "final.mp4")) + output_path.parent.mkdir(parents=True, exist_ok=True) + + steps: dict[str, Any] = {} + + # 1. Scaffold — generate HTML/CSS/assets. + scaffold = self._scaffold(inputs) + steps["scaffold"] = scaffold.data + if not scaffold.success: + return ToolResult( + success=False, + error=f"Scaffold failed: {scaffold.error}", + data={"steps": steps}, + ) + + # 2. Lint — static contract checks. + lint = self._lint({"workspace_path": str(workspace)}) + steps["lint"] = lint.data + if not lint.success: + if inputs.get("strict", False): + return ToolResult( + success=False, + error=f"Lint failed (strict mode): {lint.error}", + data={"steps": steps}, + ) + log.warning("hyperframes lint reported issues (non-strict mode, continuing)") + + # 3. Validate — browser-based contract + contrast. + validate = self._validate( + { + "workspace_path": str(workspace), + "skip_contrast": inputs.get("skip_contrast", False), + } + ) + steps["validate"] = validate.data + if not validate.success: + return ToolResult( + success=False, + error=( + f"Validate failed: {validate.error}. HyperFrames render " + f"is blocked — fix the composition and re-run." + ), + data={"steps": steps}, + ) + + # 4. Render. + width, height, fps = self._resolve_dimensions( + inputs.get("profile"), inputs.get("fps", 30) + ) + quality = inputs.get("quality", "standard") + args = [ + "render", + "--output", str(output_path), + "--fps", str(fps), + "--quality", quality, + ] + proc = self._run_hf(args, cwd=workspace, timeout=1800, check=False) + steps["render"] = { + "exit_code": proc.returncode, + "stdout_tail": (proc.stdout or "")[-4000:], + "stderr_tail": (proc.stderr or "")[-4000:], + } + if proc.returncode != 0: + return ToolResult( + success=False, + error=f"hyperframes render exit {proc.returncode}", + data={"steps": steps}, + ) + + if not output_path.exists(): + return ToolResult( + success=False, + error=( + f"hyperframes render exited 0 but output file missing: " + f"{output_path}. Check stdout_tail for the real path." + ), + data={"steps": steps}, + ) + + return ToolResult( + success=True, + data={ + "operation": "render", + "output": str(output_path), + "workspace": str(workspace), + "width": width, + "height": height, + "fps": fps, + "quality": quality, + "steps": steps, + }, + artifacts=[str(output_path)], + ) + + # ------------------------------------------------------------------ + # Workspace generation helpers + # ------------------------------------------------------------------ + + @staticmethod + def _require_workspace(inputs: dict[str, Any]) -> Path: + raw = inputs.get("workspace_path") + if not raw: + raise ValueError("workspace_path is required for this operation") + return Path(raw).resolve() + + @staticmethod + def _resolve_dimensions( + profile_name: Optional[str], fps_in: int + ) -> tuple[int, int, int]: + """Resolve output dimensions from the media profile, with a safe default.""" + if profile_name: + try: + from lib.media_profiles import get_profile # type: ignore + p = get_profile(profile_name) + return int(p.width), int(p.height), int(p.fps) + except Exception: + pass + return 1920, 1080, int(fps_in) + + @staticmethod + def _compute_total_duration(cuts: list[dict]) -> float: + if not cuts: + return 0.0 + return max(float(c.get("out_seconds", 0) or 0) for c in cuts) + + def _resolve_and_stage_assets( + self, + cuts: list[dict], + assets: list[dict], + workspace: Path, + ) -> tuple[list[dict], list[dict[str, str]]]: + """Resolve asset IDs in cuts[].source, copy files into workspace/assets/. + + HyperFrames resolves `src=` relative to the composition HTML file, so + every asset must live inside the workspace tree. Copying is simpler + (and portable) than symlinking, at the cost of disk space — these + are regenerable under `projects/`. + """ + asset_lookup = {a["id"]: a for a in assets if "id" in a} + assets_dir = workspace / "assets" + copies: list[dict[str, str]] = [] + resolved: list[dict] = [] + for cut in cuts: + source = cut.get("source", "") + resolved_cut = dict(cut) + if source in asset_lookup: + resolved_cut["source"] = asset_lookup[source].get("path", source) + src_path = Path(resolved_cut["source"]) if resolved_cut.get("source") else None + if src_path and src_path.exists() and not self._is_inside(src_path, workspace): + dest = assets_dir / src_path.name + if not dest.exists() or dest.stat().st_size != src_path.stat().st_size: + shutil.copy2(src_path, dest) + resolved_cut["source"] = str(dest) + copies.append({"from": str(src_path), "to": str(dest)}) + resolved.append(resolved_cut) + return resolved, copies + + def _resolve_audio_refs( + self, + audio: dict[str, Any], + assets: list[dict], + workspace: Path, + ) -> dict[str, Any]: + """Resolve narration / music asset IDs and stage them.""" + asset_lookup = {a["id"]: a for a in assets if "id" in a} + assets_dir = workspace / "assets" + out: dict[str, Any] = {"narration": [], "music": None} + + for seg in audio.get("narration", {}).get("segments", []) or []: + aid = seg.get("asset_id") + if not aid or aid not in asset_lookup: + continue + src = Path(asset_lookup[aid].get("path", "")) + if not src.exists(): + continue + if not self._is_inside(src, workspace): + dest = assets_dir / src.name + if not dest.exists() or dest.stat().st_size != src.stat().st_size: + shutil.copy2(src, dest) + else: + dest = src + out["narration"].append( + { + "src": str(dest), + "start_seconds": float(seg.get("start_seconds", 0) or 0), + "end_seconds": float(seg.get("end_seconds", 0) or 0) or None, + } + ) + + music = audio.get("music", {}) + m_id = music.get("asset_id") + if m_id and m_id in asset_lookup: + src = Path(asset_lookup[m_id].get("path", "")) + if src.exists(): + if not self._is_inside(src, workspace): + dest = assets_dir / src.name + if not dest.exists() or dest.stat().st_size != src.stat().st_size: + shutil.copy2(src, dest) + else: + dest = src + out["music"] = { + "src": str(dest), + "volume": float(music.get("volume", 0.15) or 0.15), + "fade_in_seconds": float(music.get("fade_in_seconds", 0) or 0), + "fade_out_seconds": float(music.get("fade_out_seconds", 0) or 0), + } + + return out + + @staticmethod + def _is_inside(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except ValueError: + return False + + def _style_bridge( + self, + playbook: dict[str, Any], + edit_decisions: dict[str, Any], + ) -> tuple[dict[str, str], str]: + """Bridge OpenMontage playbook → HyperFrames CSS vars + DESIGN.md. + + Delegates to `lib/hyperframes_style_bridge.py` so the logic is + shareable and testable. Falls back to a safe built-in default when + the bridge module isn't available. + """ + try: + from lib.hyperframes_style_bridge import style_bridge # type: ignore + return style_bridge(playbook, edit_decisions) + except Exception as e: + log.debug("style_bridge fallback: %s", e) + + vl = (playbook or {}).get("visual_language", {}) + palette = vl.get("color_palette", {}) + typo = (playbook or {}).get("typography", {}) + + def _first(raw: Any, default: str) -> str: + if isinstance(raw, list) and raw: + return str(raw[0]) + if isinstance(raw, str) and raw: + return raw + return default + + bg = _first(palette.get("background"), "#0B0F1A") + fg = _first(palette.get("text"), "#F5F5F5") + accent = _first(palette.get("accent"), "#F59E0B") + primary = _first(palette.get("primary"), "#2563EB") + heading = typo.get("heading", {}).get("font") or typo.get("heading", {}).get("family") or "Inter" + body = typo.get("body", {}).get("font") or typo.get("body", {}).get("family") or "Inter" + + css_vars = { + "--color-bg": bg, + "--color-fg": fg, + "--color-accent": accent, + "--color-primary": primary, + "--font-heading": heading, + "--font-body": body, + "--ease-primary": "cubic-bezier(0.65, 0, 0.35, 1)", + "--duration-entrance": "0.6s", + } + design_md = ( + "# DESIGN\n\n" + "Generated by OpenMontage HyperFrames style bridge (fallback).\n\n" + f"- Background: `{bg}`\n" + f"- Foreground: `{fg}`\n" + f"- Accent: `{accent}`\n" + f"- Primary: `{primary}`\n" + f"- Heading font: `{heading}`\n" + f"- Body font: `{body}`\n" + ) + return css_vars, design_md + + # ------------------------------------------------------------------ + # HTML generation (minimal, Phase 1) + # ------------------------------------------------------------------ + + def _generate_index_html( + self, + cuts: list[dict], + audio_refs: dict[str, Any], + width: int, + height: int, + total_duration: float, + css_vars: dict[str, str], + title: str, + ) -> str: + """Emit a HyperFrames-contract-compliant index.html. + + Phase 1 covers the minimum required for smoke-testing the runtime: + - still images (img.clip) + - video clips (video.clip, muted playsinline + separate audio if needed) + - text cards (div.clip with styled

) + - narration segments (audio) + - music bed (audio, lower volume) + + Richer scene types (registry blocks, kinetic typography) are authored + by the agent directly into compositions/ — this generator just + provides a functional starting skeleton. + """ + vars_css = "\n ".join(f"{k}: {v};" for k, v in css_vars.items()) + + clip_html: list[str] = [] + entrance_tweens: list[str] = [] + for i, cut in enumerate(cuts): + html, tween = self._cut_to_html(i, cut, width, height) + clip_html.append(html) + if tween: + entrance_tweens.append(tween) + + audio_html: list[str] = [] + for j, nar in enumerate(audio_refs.get("narration") or []): + src = self._rel_from_workspace(nar["src"]) + start = nar.get("start_seconds", 0) + end = nar.get("end_seconds") + duration = (end - start) if end and end > start else (total_duration - start) + audio_html.append( + f'' + ) + + music = audio_refs.get("music") + if music: + src = self._rel_from_workspace(music["src"]) + audio_html.append( + f'' + ) + + tween_block = "\n ".join(entrance_tweens) if entrance_tweens else "// no tweens" + + return f""" + + + + {self._escape_text(title)} + + + + +
+ {"".join(clip_html)} + {"".join(audio_html)} + +
+ + +""" + + def _cut_to_html( + self, index: int, cut: dict, width: int, height: int + ) -> tuple[str, Optional[str]]: + """Render one cut + its entrance tween. Returns (html, tween or None).""" + cut_id = f"cut-{index}" + in_s = float(cut.get("in_seconds", 0) or 0) + out_s = float(cut.get("out_seconds", 0) or 0) + duration = max(0.1, out_s - in_s) + + source = cut.get("source") or "" + cut_type = (cut.get("type") or "").lower() + text = cut.get("text") or cut.get("title") or "" + + src_path = Path(source) if source else None + ext = src_path.suffix.lower() if src_path else "" + + # Decide scene shape + if cut_type in {"text_card", "hero_title", "callout"} or (not source and text): + inner = f'

{self._escape_text(text or f"Scene {index + 1}")}

' + subtitle = cut.get("subtitle") or cut.get("caption") + if subtitle: + inner += f'
{self._escape_text(subtitle)}
' + html = ( + f'
{inner}
' + ) + # Mild entrance — fade + lift. + tween = ( + f'tl.from("#{cut_id} h1", {{ y: 40, opacity: 0, duration: 0.6, ' + f'ease: "power3.out" }}, {self._f(in_s + 0.1)});' + ) + return html, tween + + if ext in _IMAGE_EXTENSIONS and src_path: + rel = self._rel_from_workspace(str(src_path)) + html = ( + f'' + ) + tween = ( + f'tl.from("#{cut_id}", {{ scale: 1.05, opacity: 0, duration: 0.5, ' + f'ease: "power2.out" }}, {self._f(in_s)});' + ) + return html, tween + + if ext in _VIDEO_EXTENSIONS and src_path: + rel = self._rel_from_workspace(str(src_path)) + html = ( + f'' + ) + return html, None + + # Unknown cut shape — render a placeholder text card so the render + # still succeeds; lint/validate will surface the issue. + placeholder = self._escape_text(text or cut.get("reason") or f"Scene {index + 1}") + html = ( + f'

{placeholder}

' + ) + return html, None + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + + def _run_hf( + self, + args: list[str], + *, + cwd: Optional[Path], + timeout: int, + check: bool, + ) -> subprocess.CompletedProcess: + """Invoke `npx hyperframes ` with the right Windows quirks. + + We intentionally bypass `self.run_command` here because we do NOT + want to raise CalledProcessError on non-zero exits — the caller + parses lint/validate/render exit codes itself. + """ + cmd = ["npx", "--yes", "hyperframes", *args] + # On Windows, resolve the .cmd wrapper so subprocess can find it + # without shell=True. + if os.name == "nt": + resolved = shutil.which(cmd[0]) + if resolved: + cmd[0] = resolved + try: + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + cwd=str(cwd) if cwd else None, + check=False, + ) + except subprocess.TimeoutExpired as e: + # Surface timeouts as a failed CompletedProcess so callers get a + # uniform shape. The stderr tail will say timeout. + return subprocess.CompletedProcess( + args=cmd, + returncode=124, + stdout=e.stdout or "", + stderr=(e.stderr or "") + f"\n[timeout after {timeout}s]", + ) + + @staticmethod + def _parse_json_output(stdout: str) -> Optional[Any]: + """Parse a `--json` report, tolerating surrounding banner lines.""" + if not stdout: + return None + start = stdout.find("{") + end = stdout.rfind("}") + if start == -1 or end == -1 or end <= start: + return None + try: + return json.loads(stdout[start : end + 1]) + except json.JSONDecodeError: + return None + + @staticmethod + def _f(v: float) -> str: + return f"{float(v):.3f}".rstrip("0").rstrip(".") + + @staticmethod + def _escape_text(s: str) -> str: + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + @staticmethod + def _escape_attr(s: str) -> str: + return HyperFramesCompose._escape_text(s).replace('"', """) + + @staticmethod + def _rel_from_workspace(path: str) -> str: + """HyperFrames resolves src= relative to index.html. Our asset files + live under workspace/assets/, so when we stage a copy we know the + relative path is `assets/`. For files already in the workspace + tree, fall back to the file name. + """ + p = Path(path) + # If it's already a relative path starting with assets/, keep as-is. + if not p.is_absolute(): + return str(p).replace("\\", "/") + # Otherwise emit just the basename under assets/. + return f"assets/{p.name}" diff --git a/tools/video/remotion_caption_burn.py b/tools/video/remotion_caption_burn.py index d3dc8b0..35ff6ba 100644 --- a/tools/video/remotion_caption_burn.py +++ b/tools/video/remotion_caption_burn.py @@ -1,7 +1,7 @@ -"""Remotion caption burn tool. +"""Remotion caption burn tool — **runtime-specific (Remotion-only)**. Renders animated word-by-word captions onto a talking-head video using -the Remotion CaptionOverlay component. Falls back to FFmpeg subtitle +the Remotion CaptionOverlay component. Falls back to FFmpeg subtitle burning if Remotion is not available. The tool: @@ -12,6 +12,20 @@ The tool: Fallback: if Remotion is unavailable, burns subtitles at the bottom of the frame using FFmpeg's ``subtitles`` filter with bold styling. + +## Runtime scope + +This tool is **Remotion-specific** and deliberately has no HyperFrames +counterpart in Phase 1. Word-level caption burn parity on the HyperFrames +runtime is explicitly deferred work (see ``skills/core/hyperframes.md`` → +"What stays Remotion-only in Phase 1"). + +If a brief requires word-level/karaoke captions, lock +``render_runtime="remotion"`` at proposal even if the rest of the +composition would otherwise be a good fit for HyperFrames. Do NOT attempt +to bolt this tool onto a HyperFrames workspace — the TalkingHead +composition ID and ``WordCaption`` prop shape it emits are tied to the +React scene stack in ``remotion-composer/``. """ from __future__ import annotations diff --git a/tools/video/video_compose.py b/tools/video/video_compose.py index b6bcb1f..ea22a6b 100644 --- a/tools/video/video_compose.py +++ b/tools/video/video_compose.py @@ -1,13 +1,23 @@ -"""Video composition tool — FFmpeg + Remotion. +"""Video composition tool — FFmpeg + Remotion + HyperFrames (runtime-aware). -Final composition path that takes edit decisions, assets, and audio -and renders the complete output video. Supports subtitle burn-in, -overlay compositing, and platform-specific encoding profiles. +Pipeline-facing orchestration surface for composition. Takes `edit_decisions`, +`asset_manifest`, and audio, and delegates to the technical runtime chosen +at proposal stage. -For compositions with still images, animated scenes, or component types -(text cards, stat cards, etc.), the render operation auto-routes to -Remotion for frame-accurate spring animations and React-based rendering. -For pure video cuts (talking-head, etc.), FFmpeg handles trimming and concat. +Routing is driven by `edit_decisions.render_runtime` (locked at proposal): + +- `remotion` → React-based frame-accurate render via `npx remotion render`. + Handles the existing scene-component stack, word-level captions, + TalkingHead/CinematicRenderer. Current default. +- `hyperframes` → HTML/CSS/GSAP render via `hyperframes_compose`. + Handles kinetic typography, product promos, website-to-video, + registry blocks. Added in the parallel-runtime initiative. +- `ffmpeg` → FFmpeg concat/trim. Used only for simple video cuts without + composition, or when the approved path explicitly names FFmpeg. + +Silent runtime swaps are forbidden by governance. If the chosen runtime is +unavailable or fails, this tool surfaces a structured blocker and waits for +the agent to re-ask the user rather than substituting a different engine. """ from __future__ import annotations @@ -84,6 +94,17 @@ class VideoCompose(BaseTool): "Used to resolve asset IDs in cuts[].source to file paths." ), }, + "proposal_packet": { + "type": "object", + "description": ( + "Full proposal_packet artifact. Optional but STRONGLY " + "recommended — when present, final_review compares " + "proposal_packet.production_plan.render_runtime against " + "edit_decisions.render_runtime and flags runtime_swap_detected. " + "Without it, runtime-swap detection falls back to checking " + "edit_decisions.metadata.proposal_render_runtime." + ), + }, "subtitle_path": {"type": "string"}, "subtitle_style": { "type": "object", @@ -177,42 +198,85 @@ class VideoCompose(BaseTool): return False return True - def get_info(self) -> dict[str, Any]: - """Extend base get_info to surface Remotion sub-capability. + def _hyperframes_available(self) -> bool: + """Check if HyperFrames rendering is available. - This lets preflight report 'video_compose: AVAILABLE (FFmpeg + Remotion)' - so the agent knows Remotion is usable for motion graphics, animated text - cards, stat cards, charts, and image-to-video rendering — rather than - falling back to Ken Burns pan-and-zoom over still images. + Delegates to the dedicated tool so the availability check stays in + one place (node 22 floor, ffmpeg + npx on PATH). + """ + try: + from tools.video.hyperframes_compose import HyperFramesCompose + return bool(HyperFramesCompose()._runtime_check()["runtime_available"]) + except Exception: + return False + + def get_info(self) -> dict[str, Any]: + """Extend base get_info to surface all available render runtimes. + + Preflight reports each runtime's availability separately so the agent + can choose an appropriate `render_runtime` at proposal stage. Silent + fallback between runtimes is forbidden. """ info = super().get_info() remotion_ok = self._remotion_available() + hyperframes_ok = self._hyperframes_available() info["render_engines"] = { "ffmpeg": True, "remotion": remotion_ok, + "hyperframes": hyperframes_ok, } + # Backwards-compat alias — some proposal skills inspect this name. + info["render_runtimes"] = info["render_engines"] + if remotion_ok: info["remotion_components"] = self._REMOTION_COMPONENTS info["remotion_note"] = ( "Remotion is available for React-based rendering. Use it for " "image-to-video with spring animations, animated text/stat cards, " - "charts, callouts, comparisons, and complex transitions. " + "charts, callouts, comparisons, and word-level caption burn. " "Prefer Remotion over Ken Burns pan-and-zoom for explainer " - "and motion-graphics pipelines." + "and motion-graphics pipelines that already use the scene-component stack." ) else: composer_dir = Path(__file__).resolve().parent.parent.parent / "remotion-composer" if composer_dir.exists() and (composer_dir / "package.json").exists() and not (composer_dir / "node_modules").exists(): info["remotion_note"] = ( "Remotion project exists but node_modules are NOT installed. " - "Run 'cd remotion-composer && npm install' to enable Remotion rendering. " - "Falling back to FFmpeg Ken Burns for image-based compositions." + "Run 'cd remotion-composer && npm install' to enable Remotion rendering." ) else: info["remotion_note"] = ( - "Remotion is NOT available (needs Node.js/npx + remotion-composer). " - "Falling back to FFmpeg Ken Burns for image-based compositions." + "Remotion is NOT available (needs Node.js/npx + remotion-composer + node_modules)." ) + + if hyperframes_ok: + info["hyperframes_note"] = ( + "HyperFrames is available for HTML/CSS/GSAP composition. Use it " + "for kinetic typography, product promos, launch reels, " + "website-to-video, and registry-block-driven scenes. Consumed via " + "'npx hyperframes' (npm package: 'hyperframes'). " + "Before locking render_runtime='hyperframes' at the proposal stage, " + "verify the runtime with `hyperframes_compose` operation='doctor' " + "or `make hyperframes-doctor`. An 'available' flag from the runtime " + "check means node + ffmpeg + the npm package all resolve; it does " + "not guarantee a render will succeed on the first specific " + "composition." + ) + else: + info["hyperframes_note"] = ( + "HyperFrames is NOT available. Requires Node.js >= 22, FFmpeg, " + "npx on PATH, and the 'hyperframes' npm package to be resolvable. " + "Run `make hyperframes-doctor` to see the specific missing piece, " + "or call `hyperframes_compose` operation='doctor' directly." + ) + + # Governance note — agents and reviewers consume this. + info["runtime_governance"] = ( + "render_runtime is locked at proposal stage and carried unchanged " + "through edit_decisions. Silent swaps are forbidden. If the " + "chosen runtime fails, surface a structured blocker and wait for " + "user approval before switching." + ) return info def execute(self, inputs: dict[str, Any]) -> ToolResult: @@ -784,7 +848,10 @@ class VideoCompose(BaseTool): if scenes: try: from lib.slideshow_risk import score_slideshow_risk - risk = score_slideshow_risk(scenes, edit_decisions, renderer_family) + render_runtime = edit_decisions.get("render_runtime") + risk = score_slideshow_risk( + scenes, edit_decisions, renderer_family, render_runtime + ) if risk["verdict"] == "fail": blocks.append( f"Slideshow risk score {risk['average']:.1f}/5.0 (verdict: fail). " @@ -875,7 +942,55 @@ class VideoCompose(BaseTool): # Also accept profile as "output_profile" (skill convention) or "profile" profile = inputs.get("profile") or inputs.get("output_profile") - # --- Route: Remotion by default, FFmpeg only when Remotion unavailable --- + # --- Runtime routing: honor render_runtime locked at proposal --- + # Silent swaps are forbidden by governance. If the chosen runtime + # is unavailable, surface a structured blocker rather than quietly + # picking a different engine. Missing render_runtime is itself a + # governance violation — edit_decisions.schema.json requires it. + render_runtime = (edit_decisions.get("render_runtime") or "").strip().lower() + + if not render_runtime: + return ToolResult( + success=False, + error=( + "render_runtime is not set in edit_decisions. Per governance, " + "it MUST be locked at proposal stage (proposal_packet." + "production_plan.render_runtime) and carried forward through " + "edit_decisions.render_runtime. Valid values: 'remotion', " + "'hyperframes', 'ffmpeg'. Re-run the proposal stage with an " + "explicit runtime choice — do NOT default this field." + ), + ) + + if render_runtime == "hyperframes": + return self._render_via_hyperframes( + inputs=inputs, + edit_decisions=edit_decisions, + asset_manifest=asset_manifest, + resolved_cuts=resolved_cuts, + output_path=output_path, + profile=profile, + ) + if render_runtime == "ffmpeg": + # Caller explicitly asked for FFmpeg — don't auto-upgrade to Remotion. + return self._render_via_ffmpeg( + inputs=inputs, + edit_decisions=edit_decisions, + resolved_cuts=resolved_cuts, + output_path=output_path, + profile=profile, + ) + if render_runtime != "remotion": + return ToolResult( + success=False, + error=( + f"Unknown render_runtime {render_runtime!r}. " + f"Valid values: remotion, hyperframes, ffmpeg. " + f"render_runtime must be set at proposal stage." + ), + ) + + # --- Explicit Remotion path (render_runtime == 'remotion') --- if self._needs_remotion(resolved_cuts): remotion_inputs: dict[str, Any] = { "edit_decisions": dict(edit_decisions, cuts=resolved_cuts), @@ -927,7 +1042,9 @@ class VideoCompose(BaseTool): # --- Post-render: mandatory final self-review --- if render_result.success and output_path.exists(): - final_review = self._run_final_review(output_path, edit_decisions) + final_review = self._run_final_review( + output_path, edit_decisions, inputs.get("proposal_packet") + ) # Attach final_review to the ToolResult data so the compose-director # skill can include it in the checkpoint alongside the render_report. @@ -949,6 +1066,172 @@ class VideoCompose(BaseTool): return render_result + def _render_via_hyperframes( + self, + *, + inputs: dict[str, Any], + edit_decisions: dict[str, Any], + asset_manifest: dict[str, Any], + resolved_cuts: list[dict], + output_path: Path, + profile: Optional[str], + ) -> ToolResult: + """Delegate to hyperframes_compose and run the mandatory final self-review. + + Governance: if HyperFrames is unavailable or fails, return a structured + blocker — do NOT silently route to Remotion or FFmpeg. The agent must + surface the blocker and get user approval before any runtime swap. + """ + if not self._hyperframes_available(): + return ToolResult( + success=False, + error=( + "render_runtime='hyperframes' was locked at proposal, but " + "the HyperFrames runtime is not available on this machine. " + "Per governance this is a BLOCKER — surface it to the user " + "per AGENT_GUIDE.md > 'Escalate Blockers Explicitly' and wait " + "for approval before switching runtime. Requirements: " + "Node.js >= 22, FFmpeg, and npx on PATH. See " + "tools/video/hyperframes_compose.py for the specific missing piece." + ), + ) + + try: + from tools.video.hyperframes_compose import HyperFramesCompose + except Exception as e: + return ToolResult( + success=False, + error=f"Could not import hyperframes_compose: {e}", + ) + + workspace_path = ( + inputs.get("workspace_path") + or str(output_path.parent.parent / "hyperframes") + ) + + # Pass the playbook through so the style bridge can emit CSS vars. + playbook_data = inputs.get("playbook") + if not playbook_data: + playbook_name = ( + inputs.get("playbook_name") + or (edit_decisions.get("metadata") or {}).get("playbook") + ) + if playbook_name: + try: + from styles.playbook_loader import load_playbook # type: ignore + playbook_data = load_playbook(playbook_name) + except Exception: + playbook_data = None + + hf_inputs: dict[str, Any] = { + "operation": "render", + "workspace_path": workspace_path, + "output_path": str(output_path), + "edit_decisions": dict(edit_decisions, cuts=resolved_cuts), + "asset_manifest": asset_manifest, + } + if playbook_data: + hf_inputs["playbook"] = playbook_data + if profile: + hf_inputs["profile"] = profile + if "quality" in inputs: + hf_inputs["quality"] = inputs["quality"] + if "fps" in inputs: + hf_inputs["fps"] = inputs["fps"] + if "strict" in inputs: + hf_inputs["strict"] = inputs["strict"] + if "skip_contrast" in inputs: + hf_inputs["skip_contrast"] = inputs["skip_contrast"] + + render_result = HyperFramesCompose().execute(hf_inputs) + + if not render_result.success: + return ToolResult( + success=False, + error=( + f"HyperFrames render failed: {render_result.error}. " + "Per governance: do NOT silently fall back to Remotion or " + "FFmpeg. Surface the failure to the user along with the " + "hyperframes_compose step log before proposing a swap." + ), + data=render_result.data, + ) + + # Post-render: mandatory final self-review (identical contract to the Remotion path). + if output_path.exists(): + final_review = self._run_final_review( + output_path, edit_decisions, inputs.get("proposal_packet") + ) + if render_result.data is None: + render_result.data = {} + render_result.data["final_review"] = final_review + render_result.data["final_review_status"] = final_review["status"] + if final_review["status"] == "fail": + return ToolResult( + success=False, + error=( + "Post-render self-review FAILED (HyperFrames). The output is not presentable.\n" + + "\n".join(f" • {i}" for i in final_review.get("issues_found", [])) + ), + data=render_result.data, + ) + + return render_result + + def _render_via_ffmpeg( + self, + *, + inputs: dict[str, Any], + edit_decisions: dict[str, Any], + resolved_cuts: list[dict], + output_path: Path, + profile: Optional[str], + ) -> ToolResult: + """Explicit FFmpeg-only render path. + + Use when the proposal locked `render_runtime="ffmpeg"` — e.g. simple + source-footage concat/trim jobs that don't benefit from composition. + Still runs the mandatory final self-review. + """ + options = inputs.get("options", {}) + subtitle_burn = options.get("subtitle_burn", True) + + subtitle_path = inputs.get("subtitle_path") + if subtitle_burn and not subtitle_path: + ed_subs = edit_decisions.get("subtitles", {}) + if ed_subs.get("enabled") and ed_subs.get("source"): + subtitle_path = ed_subs["source"] + + compose_inputs = dict(inputs) + compose_inputs["edit_decisions"] = dict(edit_decisions, cuts=resolved_cuts) + compose_inputs["output_path"] = str(output_path) + if subtitle_path: + compose_inputs["subtitle_path"] = subtitle_path + if profile: + compose_inputs["profile"] = profile + + render_result = self._compose(compose_inputs) + + if render_result.success and output_path.exists(): + final_review = self._run_final_review( + output_path, edit_decisions, inputs.get("proposal_packet") + ) + if render_result.data is None: + render_result.data = {} + render_result.data["final_review"] = final_review + render_result.data["final_review_status"] = final_review["status"] + if final_review["status"] == "fail": + return ToolResult( + success=False, + error=( + "Post-render self-review FAILED (FFmpeg). The output is not presentable.\n" + + "\n".join(f" • {i}" for i in final_review.get("issues_found", [])) + ), + data=render_result.data, + ) + + return render_result + def _remotion_render(self, inputs: dict[str, Any]) -> ToolResult: """Render via Remotion (requires Node.js + npx). @@ -1074,6 +1357,7 @@ class VideoCompose(BaseTool): self, output_path: Path, edit_decisions: dict[str, Any] | None = None, + proposal_packet: dict[str, Any] | None = None, ) -> dict[str, Any]: """Run post-render self-review and produce a final_review artifact. @@ -1081,6 +1365,13 @@ class VideoCompose(BaseTool): the actual rendered output before marking the stage complete. Never claim a video is ready without a real probe + frame sample. + When `proposal_packet` is provided, its + `production_plan.render_runtime` is compared against + `edit_decisions.render_runtime` so `runtime_swap_detected` can + actually flip. Without it, we fall back to + `edit_decisions.metadata.proposal_render_runtime` (which the edit + director can set explicitly to opt into swap detection). + Returns a dict conforming to final_review.schema.json. """ log = logging.getLogger("video_compose.final_review") @@ -1278,12 +1569,64 @@ class VideoCompose(BaseTool): promise_preservation: dict[str, Any] = { "delivery_promise_honored": True, "silent_downgrade_detected": False, + "runtime_swap_detected": False, "issues": [], } if edit_decisions: renderer_family = edit_decisions.get("renderer_family", "") promise_preservation["renderer_family_used"] = renderer_family + # Runtime governance — record what actually ran and flag a swap. + # Three sources of truth, in priority order: + # 1. proposal_packet.production_plan.render_runtime (authoritative) + # 2. edit_decisions.metadata.proposal_render_runtime (if edit stage + # explicitly copied it to opt into in-tool swap detection) + # 3. edit_decisions.render_runtime itself (cannot detect a swap in + # this case — reviewer does cross-artifact comparison instead) + render_runtime_edit = (edit_decisions.get("render_runtime") or "").strip().lower() + if render_runtime_edit: + promise_preservation["render_runtime_used"] = render_runtime_edit + + proposal_runtime: str | None = None + runtime_source: str | None = None + if proposal_packet: + pp_runtime = ( + (proposal_packet.get("production_plan") or {}).get("render_runtime") + or "" + ).strip().lower() + if pp_runtime: + proposal_runtime = pp_runtime + runtime_source = "proposal_packet.production_plan.render_runtime" + if proposal_runtime is None: + md_runtime = ( + (edit_decisions.get("metadata") or {}).get("proposal_render_runtime") + or "" + ).strip().lower() + if md_runtime: + proposal_runtime = md_runtime + runtime_source = "edit_decisions.metadata.proposal_render_runtime" + + if proposal_runtime is None: + promise_preservation["runtime_swap_check"] = ( + "skipped — no proposal_packet or proposal_render_runtime " + "metadata provided. Reviewer skill does cross-artifact " + "comparison separately." + ) + elif proposal_runtime != render_runtime_edit: + promise_preservation["runtime_swap_detected"] = True + promise_preservation["runtime_swap_check"] = ( + f"detected — source: {runtime_source}" + ) + promise_preservation["issues"].append( + f"render_runtime changed between proposal ({proposal_runtime}) " + f"and compose ({render_runtime_edit}) — this is a contract " + f"violation unless a render_runtime_selection decision was logged." + ) + else: + promise_preservation["runtime_swap_check"] = ( + f"ok — proposal and edit agree ({runtime_source})" + ) + delivery_data = ( edit_decisions.get("metadata", {}).get("delivery_promise") or edit_decisions.get("delivery_promise")