From e934b1306b44c1af4883d2434d46e04a1a72dd9b Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Mon, 6 Jul 2026 22:18:00 -0700 Subject: [PATCH 01/34] docs: design spec for hyperframes integration (4 tracks) Shader transition port, argo add + registry compat, block pre-render adapter, and the 'better together' showcase demo. Approved 2026-07-06. --- ...26-07-06-hyperframes-integration-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-hyperframes-integration-design.md diff --git a/docs/superpowers/specs/2026-07-06-hyperframes-integration-design.md b/docs/superpowers/specs/2026-07-06-hyperframes-integration-design.md new file mode 100644 index 0000000..05a1805 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-hyperframes-integration-design.md @@ -0,0 +1,123 @@ +# Argo × HyperFrames Integration — Design + +**Date:** 2026-07-06 +**Status:** Approved +**Scope:** Four sequential tracks adopting HyperFrames capabilities in Argo, culminating in a showcase demo video. + +## Background + +[HyperFrames](https://github.com/heygen-com/hyperframes) (Apache-2.0) is HeyGen's "write HTML, render video" framework. Both tools converged on the same primitive — a GSAP timeline scoped under a `data-*` attribute root — which makes their content interoperable: HyperFrames *seeks* a paused timeline deterministically; Argo can *play* the same timeline live, or seek it in a pre-render pass. + +Its registry catalog holds **142 items** (109 blocks, 25 components, 8 examples) at +`https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/registry.json`. + +- **Block** — standalone HTML composition (fixed canvas, typically 1920×1080; fixed duration) with scoped styles and a script that builds a **paused** GSAP timeline registered on `window.__timelines[]`. Typed params exposed as CSS custom properties, declared in `registry-item.json`. +- **Component** — HTML + scoped CSS snippet (`pointer-events: none`), params via CSS custom properties, optional GSAP timeline-integration hints in comments. + +All 12 of Argo's existing blocks have HyperFrames counterparts; the catalog's remaining ~130 items are the payoff. + +## Goals + +1. Expand Argo's shader transition library from 5 to ~18 (Track 1). +2. Let users install and use HyperFrames catalog items with one command (Track 2). +3. Composite full HyperFrames blocks into exported videos, frame-exact (Track 3). +4. Ship a demo video proving the combo: **"Argo + HyperFrames — better together"** (Track 4). + +## Non-Goals + +- Caption components (`caption-*`, 18 items) — blocked on the word-level STT roadmap item (v0.38 candidate). Explicitly deferred. +- An Argo-native registry server. The registry URL is configurable; v1 points at the HyperFrames GitHub raw registry only. +- Converting HyperFrames items to Argo `BlockDefinition` format. Items are stored native and adapted at use time. +- Per-boundary shader selection (pre-existing limitation, unchanged). + +--- + +## Track 1 — Shader transitions port + +**Source:** `packages/shader-transitions/src/shaders/registry.ts` (fragment shaders as minified JS strings) + `common.ts` (`H` header/varyings, `NQ` noise helpers). +**Target:** self-contained, formatted, commented `.glsl` files in `src/transitions/shaders/`, matching the existing five. + +- Port all non-duplicates (~13): `domain-warp`, `ridged-burn`, `whip-pan`, `sdf-iris`, `gravitational-lens`, `cinematic-zoom`, `chromatic-radial-split`, `glitch`, `swirl-vortex`, `thermal-distortion`, `flash-through-white`, and others. Final list determined by diffing against the existing five (`cross-warp-morph` ≈ `crosswarp`; `light-leak` exists; `ripple-waves` vs `ripple` and `swirl-vortex` vs `swirl` compared visually before inclusion). +- **Uniform shim at port time** (not runtime): `u_from`/`u_to`/`u_progress`/`v_uv` → `from`/`to`/`progress`/`vUv`. Noise helpers inlined per file so each shader stays self-contained. +- **Accent uniforms:** shaders that use `u_accent`/`u_accent_dark`/`u_accent_bright` keep them as `accent`/`accentDark`/`accentBright`. New config `export.transition.accent: '#hex'` (default `#0ea5e9`). The render harness parses the hex, derives dark (luminance-scaled) and bright (mixed toward white) variants, and supplies the three uniforms **only when the shader source declares them** — existing shaders are unaffected. +- Each file carries an attribution header: adapted from heygen-com/hyperframes, Apache-2.0. +- Build step already copies `.glsl` → `dist/`; shader cache keys already hash shader source, so new shaders cache correctly with no changes. +- Update: README shader list, `argo validate` shader-name validation, `skills/argo-guide` references. + +**Testing:** extend `tests/transitions/shader-render.test.ts` — every registered shader compiles and renders a frame in headless Chromium (the existing test pattern); accent-derivation unit tests. + +--- + +## Track 2 — `argo add` + registry compatibility + +### Command + +``` +argo add # install a block or component +argo add --list # browse the catalog (name, type, tags, description) +argo add --list --json # machine-readable +``` + +- Registry URL from `registry.url` in `argo.config.*`; default `https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry`. +- Item names validated with the same `[a-zA-Z0-9][a-zA-Z0-9_-]*` pattern as demo names before any `path.join()` (security invariant). +- Flow: fetch `registry.json` → locate item → fetch its `registry-item.json` → download declared files → write verbatim to `blocks//` (project-local, git-tracked; dir configurable via `blocksDir` in config). The `registry-item.json` is stored alongside for params metadata. Examples (`hyperframes:example`) are not installable — clear error pointing at the HyperFrames CLI. + +### Use-time adapter (Tier 1: components, 25 items) + +- New overlay cue variant: `{ type: 'hf-component', name: string, params?: Record }` in `.scenes.json`, plus a script-side API `applyComponent(page, name, params?)` exported from fixtures (fire-and-forget-safe; error handling mirrors `showConfetti` — swallow disposal errors, warn otherwise). +- Loader reads `blocks//.html`, extracts snippet HTML and ` +`; + +const SHIMMER_LIKE = ` +`; + +describe('parseComponentSnippet', () => { + it('splits a root-element component into html + css, stripping comments', () => { + const s = parseComponentSnippet(VIGNETTE_LIKE); + expect(s.html).toContain('hf-vignette'); + expect(s.html).not.toContain(' { + const s = parseComponentSnippet(SHIMMER_LIKE); + expect(s.html).toBe(''); + expect(s.css).toContain('.shimmer-sweep-target'); + expect(s.js).toContain('querySelectorAll'); + }); +}); + +describe('param safety', () => { + it('accepts normal CSS values', () => { + for (const ok of ['rgba(0, 0, 0, 0.7)', '45%', '120deg', '#ff8800', 'ellipse', '2.5s']) { + expect(isSafeCssValue(ok), ok).toBe(true); + } + }); + + it('rejects values that could escape a declaration or load resources', () => { + for (const bad of [ + 'red; background: blue', + '} body { display: none', + 'url(https://evil.test/x)', + 'expression(alert(1))', + '`; + +let browser: Browser; +let page: Page; +let tmp: string; + +beforeAll(async () => { + browser = await chromium.launch(); +}, 60_000); +afterAll(async () => { await browser.close(); }); + +beforeEach(async () => { + tmp = mkdtempSync(join(tmpdir(), 'argo-apply-')); + mkdirSync(join(tmp, 'vignette'), { recursive: true }); + writeFileSync(join(tmp, 'vignette', 'vignette.html'), SNIPPET); + page = await browser.newPage(); + await page.setContent('

app

'); +}); +afterEach(async () => { + await page.close(); + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('resolveBlocksDir', () => { + it('prefers explicit, then env, then "blocks"', () => { + const prev = process.env.ARGO_BLOCKS_DIR; + delete process.env.ARGO_BLOCKS_DIR; + expect(resolveBlocksDir('x')).toBe('x'); + expect(resolveBlocksDir()).toBe('blocks'); + process.env.ARGO_BLOCKS_DIR = '/tmp/bd'; + expect(resolveBlocksDir()).toBe('/tmp/bd'); + if (prev === undefined) delete process.env.ARGO_BLOCKS_DIR; else process.env.ARGO_BLOCKS_DIR = prev; + }); +}); + +describe('applyComponent / removeComponent', () => { + it('injects container + style + runs script, applies params, and removes cleanly', async () => { + await applyComponent(page, 'vignette', { + blocksDir: tmp, + params: { '--vignette-size': '30%' }, + }); + + expect(await page.locator('#argo-hf-vignette').count()).toBe(1); + expect(await page.locator('style[data-argo-hf="vignette"]').count()).toBe(1); + expect(await page.evaluate(() => document.documentElement.dataset.hfScriptRan)).toBe('1'); + expect( + await page.evaluate(() => + document.documentElement.style.getPropertyValue('--vignette-size'), + ), + ).toBe('30%'); + + await removeComponent(page, 'vignette'); + expect(await page.locator('#argo-hf-vignette').count()).toBe(0); + expect(await page.locator('style[data-argo-hf="vignette"]').count()).toBe(0); + expect( + await page.evaluate(() => + document.documentElement.style.getPropertyValue('--vignette-size'), + ), + ).toBe(''); + }); + + it('throws for a component that is not installed', async () => { + await expect(applyComponent(page, 'nope', { blocksDir: tmp })).rejects.toThrow(/argo add nope/); + }); + + it('rejects unsafe params before touching the page', async () => { + await expect( + applyComponent(page, 'vignette', { blocksDir: tmp, params: { '--x': 'red; }' } }), + ).rejects.toThrow(/unsafe/i); + await expect( + applyComponent(page, 'vignette', { blocksDir: tmp, params: { 'not-a-var': 'red' } }), + ).rejects.toThrow(/custom property/i); + }); +}, 60_000); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/hf/apply-component.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +First Read the top of `src/effects.ts` to copy its `Page` import style and its disposal-error filter helper exactly (the catch block that swallows only page/context-closed errors and warns on everything else). Then create `src/hf/apply-component.ts`: + +```typescript +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Page } from 'playwright'; +import { isSafeCssValue, isSafeCssVarName, parseComponentSnippet } from './component.js'; + +const ITEM_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; +const CONTAINER_PREFIX = 'argo-hf-'; + +export function resolveBlocksDir(explicit?: string): string { + return explicit ?? process.env.ARGO_BLOCKS_DIR ?? 'blocks'; +} + +export interface ApplyComponentOptions { + /** CSS custom property overrides, e.g. { '--vignette-size': '40%' }. */ + params?: Record; + /** Override the blocks directory (defaults to ARGO_BLOCKS_DIR or 'blocks'). */ + blocksDir?: string; +} + +/** + * Inject an installed hyperframes component (full-frame, pointer-events: + * none) into the recorded page. Persists until removeComponent() — pair + * them, or use the `hf-component` overlay cue for duration-based display. + */ +export async function applyComponent( + page: Page, + name: string, + opts?: ApplyComponentOptions, +): Promise { + if (!ITEM_NAME_RE.test(name)) { + throw new Error(`Invalid component name "${name}"`); + } + const dir = resolveBlocksDir(opts?.blocksDir); + const file = join(dir, name, `${name}.html`); + if (!existsSync(file)) { + throw new Error(`Component "${name}" is not installed (looked in ${dir}/). Run: argo add ${name}`); + } + const snippet = parseComponentSnippet(readFileSync(file, 'utf-8')); + + const params = opts?.params ?? {}; + for (const [k, v] of Object.entries(params)) { + if (!isSafeCssVarName(k)) { + throw new Error(`Param "${k}" is not a CSS custom property name (must match --kebab-case)`); + } + if (!isSafeCssValue(v)) { + throw new Error(`Unsafe CSS value for param "${k}": ${JSON.stringify(v)}`); + } + } + + // Render fence — flush pending browser renders before injecting (same + // rationale as overlay injection; see src/overlays/zones.ts). + await page.evaluate(() => {}); + await page.evaluate( + ({ name, html, css, js, params, prefix }) => { + const id = prefix + name; + document.getElementById(id)?.remove(); + document.querySelectorAll(`style[data-argo-hf="${name}"]`).forEach((el) => el.remove()); + + if (css) { + const st = document.createElement('style'); + st.setAttribute('data-argo-hf', name); + st.textContent = css; + document.head.appendChild(st); + } + + const container = document.createElement('div'); + container.id = id; + container.style.cssText = + 'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:2147482000'; + for (const [k, v] of Object.entries(params)) { + container.style.setProperty(k, v); + document.documentElement.style.setProperty(k, v); + } + container.dataset.argoHfParams = JSON.stringify(Object.keys(params)); + if (html) container.innerHTML = html; + document.body.appendChild(container); + + if (js) { + try { + new Function(js)(); + } catch (e) { + console.warn( + `[argo] component "${name}" script failed (strict CSP can block injected scripts): ${String(e)}`, + ); + } + } + }, + { name, html: snippet.html, css: snippet.css, js: snippet.js, params, prefix: CONTAINER_PREFIX }, + ); +} + +/** Remove an applied component and its params/styles. */ +export async function removeComponent(page: Page, name: string): Promise { + if (!ITEM_NAME_RE.test(name)) return; + await page.evaluate( + ({ name, prefix }) => { + const container = document.getElementById(prefix + name); + if (container?.dataset.argoHfParams) { + try { + for (const k of JSON.parse(container.dataset.argoHfParams) as string[]) { + document.documentElement.style.removeProperty(k); + } + } catch { + /* ignore malformed dataset */ + } + } + container?.remove(); + document.querySelectorAll(`style[data-argo-hf="${name}"]`).forEach((el) => el.remove()); + }, + { name, prefix: CONTAINER_PREFIX }, + ); +} +``` + +Then wrap the two `page.evaluate` bodies' outer calls in the same try/catch disposal-filter pattern `showConfetti` uses (swallow page/context-closed errors, `console.warn` everything else) — copy that catch block verbatim from `src/effects.ts` so the error-handling contract matches the rest of the effects API. Note: the param/name validation and the not-installed check must throw BEFORE the try/catch region (the test asserts they reject). + +**Export:** add to `src/index.ts`, near the `showConfetti` export (line ~57): + +```typescript +export { + applyComponent, + removeComponent, + type ApplyComponentOptions, +} from './hf/apply-component.js'; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/hf/apply-component.test.ts` +Expected: PASS (5 tests; launches chromium, ~5-15s) + +- [ ] **Step 5: Commit** + +```bash +git add src/hf/apply-component.ts src/index.ts tests/hf/apply-component.test.ts +git -c commit.gpgsign=false commit -m "feat(hf): applyComponent/removeComponent page injection" +``` + +--- + +### Task 6: `hf-component` overlay cue + env bridge + +**Files:** +- Modify: `src/overlays/types.ts` (add cue interface; extend union at :101) +- Modify: `src/overlays/index.ts` (early dispatch in `showOverlay` at :90 and `withOverlay` at :159) +- Modify: `src/overlays/templates.ts` (`renderTemplate` switch — add throw case for exhaustiveness) +- Modify: `src/record.ts` (`RecordOptions` + env at :293) +- Modify: `src/pipeline.ts:172` block, `src/pipeline.ts:527` block, `src/cli.ts:93` block (add `blocksDir: config.blocksDir,` to each `record()` options object) +- Test: `tests/hf/hf-component-cue.test.ts` + +**Interfaces:** +- Consumes: `applyComponent`, `removeComponent` (Task 5), `config.blocksDir` (Task 3). +- Produces: `HfComponentCue { type: 'hf-component'; name: string; params?: Record; placement?: Zone }` in the `OverlayCue` union; `RecordOptions.blocksDir?: string`; `ARGO_BLOCKS_DIR` env in the Playwright subprocess. Task 7 validates the cue; Task 8 documents it. + +- [ ] **Step 1: Write the failing test** + +Create `tests/hf/hf-component-cue.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { OverlayCue } from '../../src/overlays/types.js'; +import { showOverlay } from '../../src/overlays/index.js'; +import { renderTemplate } from '../../src/overlays/templates.js'; + +describe('HfComponentCue', () => { + it('is part of the OverlayCue union at compile time', () => { + const cue: OverlayCue = { type: 'hf-component', name: 'vignette', params: { '--x': '1' } }; + expect(cue.type).toBe('hf-component'); + }); + + it('renderTemplate rejects hf-component cues with a pointer to the right path', () => { + expect(() => renderTemplate({ type: 'hf-component', name: 'vignette' })).toThrow( + /full-frame/i, + ); + }); +}); + +describe('showOverlay dispatch for hf-component', () => { + let tmp: string; + let prevEnv: string | undefined; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'argo-cue-')); + mkdirSync(join(tmp, 'vignette'), { recursive: true }); + writeFileSync(join(tmp, 'vignette', 'vignette.html'), '
'); + prevEnv = process.env.ARGO_BLOCKS_DIR; + process.env.ARGO_BLOCKS_DIR = tmp; + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + if (prevEnv === undefined) delete process.env.ARGO_BLOCKS_DIR; + else process.env.ARGO_BLOCKS_DIR = prevEnv; + }); + + it('applies, waits durationMs, and removes — never touching zone machinery', async () => { + const calls: string[] = []; + const fakePage = { + evaluate: async () => { calls.push('evaluate'); }, + waitForTimeout: async (ms: number) => { calls.push(`wait:${ms}`); }, + }; + await showOverlay( + fakePage as never, + 'intro', + { type: 'hf-component', name: 'vignette' }, + 1200, + ); + // applyComponent issues 2 evaluates (fence + inject), removeComponent 1. + expect(calls).toEqual(['evaluate', 'evaluate', 'wait:1200', 'evaluate']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/hf/hf-component-cue.test.ts` +Expected: FAIL — `'hf-component'` not assignable to `OverlayCue['type']` (TS via vitest transform runs, so runtime failures: renderTemplate falls through / dispatch missing → different call sequence). + +- [ ] **Step 3: Implement** + +**3a.** In `src/overlays/types.ts`, add before the union (line ~101): + +```typescript +export interface HfComponentCue { + type: 'hf-component'; + /** Installed component name under blocksDir (see `argo add`). */ + name: string; + /** CSS custom property overrides, e.g. { '--vignette-size': '40%' }. */ + params?: Record; + /** Accepted for manifest uniformity but ignored — components are full-frame. */ + placement?: Zone; +} +``` + +and extend the union: + +```typescript +export type OverlayCue = LowerThirdCue | HeadlineCardCue | CalloutCue | ImageCardCue | ArrowCue | CustomBlockCue | HfComponentCue; +``` + +(If `Zone` is not already imported in types.ts, mirror how `placement` is typed on the other cue interfaces in this file — use exactly that type.) + +**3b.** In `src/overlays/index.ts`, import `applyComponent`/`removeComponent` from `../hf/apply-component.js`, then insert the early dispatch in `showOverlay` immediately after `cue`/`durationMs` are resolved (after the `if (typeof cueOrDuration === 'number') {...} else {...}` block, before the `const zone: Zone = ...` line): + +```typescript + if (cue.type === 'hf-component') { + // Full-frame component — bypasses zone/theme/template machinery. + await applyComponent(page, cue.name, { params: cue.params }); + await page.waitForTimeout(durationMs); + await removeComponent(page, cue.name); + return; + } +``` + +In `withOverlay`, add the equivalent branch after its cue resolution (Read the function body first — it wraps a user action; apply before running the action, remove in the `finally`): + +```typescript + if (cue.type === 'hf-component') { + await applyComponent(page, cue.name, { params: cue.params }); + try { + return await action(); + } finally { + await removeComponent(page, cue.name); + } + } +``` + +(Adjust the `action` identifier to the actual parameter name used in `withOverlay` — read it, don't guess.) + +**3c.** In `src/overlays/templates.ts`, add to the `renderTemplate` switch before the closing brace: + +```typescript + case 'hf-component': + throw new Error( + 'hf-component cues are injected full-frame by showOverlay/applyComponent, not rendered as zone templates.', + ); +``` + +**3d.** In `src/record.ts`: add to `RecordOptions` (line ~9 block): `blocksDir?: string;` — then in the env object (line ~293, next to `ARGO_OVERLAYS_PATH`): + +```typescript + ARGO_BLOCKS_DIR: path.resolve(options.blocksDir ?? 'blocks'), +``` + +**3e.** Add `blocksDir: config.blocksDir,` to the `record()` options objects at `src/pipeline.ts:172` block, `src/pipeline.ts:527` block, and `src/cli.ts:93` block (all three list `demosDir: config.demosDir,` first — add the new line right after it). + +- [ ] **Step 4: Run tests + build** + +Run: `npx vitest run tests/hf/hf-component-cue.test.ts && npx vitest run tests/overlays/ && npm run build` +Expected: new tests PASS; existing overlay tests still PASS; build exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/overlays/types.ts src/overlays/index.ts src/overlays/templates.ts src/record.ts src/pipeline.ts src/cli.ts tests/hf/hf-component-cue.test.ts +git -c commit.gpgsign=false commit -m "feat(overlays): hf-component cue + ARGO_BLOCKS_DIR env bridge" +``` + +--- + +### Task 7: `argo validate` — hf-component names + accent hex + +**Files:** +- Modify: `src/validate.ts` (validTypes at :59; overlay checks at :73-100; `ValidateOptions` at the top) +- Modify: `src/cli.ts` (validate command action — pass the two new options; Read the existing call to see how options are passed) +- Test: extend the existing validate test file (find it: `ls tests/ | grep -i validate`; if none exists, create `tests/hf/validate-hf.test.ts` using `validateDemo` directly with a temp demos dir — mirror how other tests build fixture manifests, e.g. the preview tests create temp `.scenes.json` files) + +**Interfaces:** +- Consumes: `HfComponentCue` shape (Task 6), `config.blocksDir` (Task 3), the accent regex contract from Track 1 (`/^#?[0-9a-fA-F]{6}$/`). +- Produces: `ValidateOptions.blocksDir?: string` (default `'blocks'`) and `ValidateOptions.transitionAccent?: string`; two new error classes in validate output. + +- [ ] **Step 1: Write the failing tests** + +Add tests (in the located/created test file) covering exactly these behaviors: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { validateDemo } from '../../src/validate.js'; + +describe('validate: hf-component + accent', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'argo-validate-hf-')); + mkdirSync(join(tmp, 'demos'), { recursive: true }); + mkdirSync(join(tmp, 'blocks', 'vignette'), { recursive: true }); + writeFileSync(join(tmp, 'blocks', 'vignette', 'vignette.html'), '
'); + writeFileSync( + join(tmp, 'demos', 'd.demo.ts'), + `import { test } from '@argo-video/cli';\ntest('d', async ({ page, narration }) => { narration.mark('intro'); });\n`, + ); + }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + function writeManifest(overlay: unknown) { + writeFileSync( + join(tmp, 'demos', 'd.scenes.json'), + JSON.stringify({ scenes: [{ scene: 'intro', text: 'hi', overlay }] }), + ); + } + + it('accepts an installed hf-component', async () => { + writeManifest({ type: 'hf-component', name: 'vignette' }); + const result = await validateDemo({ demo: 'd', demosDir: join(tmp, 'demos'), blocksDir: join(tmp, 'blocks') }); + expect(result.errors.filter((e) => e.includes('hf-component'))).toEqual([]); + }); + + it('errors on a missing hf-component with an install hint', async () => { + writeManifest({ type: 'hf-component', name: 'grain-overlay' }); + const result = await validateDemo({ demo: 'd', demosDir: join(tmp, 'demos'), blocksDir: join(tmp, 'blocks') }); + expect(result.errors.some((e) => /grain-overlay.*argo add/.test(e))).toBe(true); + }); + + it('errors on an hf-component cue missing "name"', async () => { + writeManifest({ type: 'hf-component' }); + const result = await validateDemo({ demo: 'd', demosDir: join(tmp, 'demos'), blocksDir: join(tmp, 'blocks') }); + expect(result.errors.some((e) => /hf-component.*name/i.test(e))).toBe(true); + }); + + it('errors on a malformed transition accent', async () => { + writeManifest(undefined); + const result = await validateDemo({ + demo: 'd', demosDir: join(tmp, 'demos'), blocksDir: join(tmp, 'blocks'), transitionAccent: 'blue', + }); + expect(result.errors.some((e) => /accent.*hex/i.test(e))).toBe(true); + }); + + it('accepts a valid transition accent', async () => { + writeManifest(undefined); + const result = await validateDemo({ + demo: 'd', demosDir: join(tmp, 'demos'), blocksDir: join(tmp, 'blocks'), transitionAccent: '#0EA5E9', + }); + expect(result.errors.filter((e) => /accent/i.test(e))).toEqual([]); + }); +}); +``` + +IMPORTANT: before finalizing these tests, Read `src/validate.ts:1-60` and one existing validate test to confirm `ValidateOptions`'s actual field names for demo/demosDir (adjust the option keys in the tests above to the real interface — the shape shown here is the expected addition, not a license to rename existing fields) and to confirm what a minimal passing fixture requires (the demo script scene-name cross-check may need the manifest and script scenes to agree, as in the fixture above). + +- [ ] **Step 2: Run to verify failures** — the hf-component manifests currently produce `unknown type "hf-component"` errors and `blocksDir`/`transitionAccent` are not accepted options (TS error at build; vitest runs untyped so expect assertion failures). + +- [ ] **Step 3: Implement in `src/validate.ts`** + +**3a.** Add to `ValidateOptions`: + +```typescript + /** Directory holding installed hyperframes items (config.blocksDir). Default 'blocks'. */ + blocksDir?: string; + /** Mirrors config `export.transition.accent` — validated as 6-digit hex when set. */ + transitionAccent?: string; +``` + +**3b.** At `:59`, add `'hf-component'` to `validTypes`. + +**3c.** After the block-specific validation (`if (ov.type === 'block') {...}` region), add: + +```typescript + // Validate hf-component-specific fields + if (ov.type === 'hf-component') { + if (!ov.name || typeof ov.name !== 'string') { + errors.push(`Scene "${entry.scene}" overlay: hf-component requires a "name" field`); + } else { + const blocksDir = options.blocksDir ?? 'blocks'; + const componentFile = path.join(blocksDir, ov.name, `${ov.name}.html`); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(ov.name)) { + errors.push(`Scene "${entry.scene}" overlay: invalid hf-component name "${ov.name}"`); + } else if (!existsSync(componentFile)) { + errors.push( + `Scene "${entry.scene}" overlay: hf-component "${ov.name}" is not installed ` + + `(missing ${componentFile}). Run: argo add ${ov.name}`, + ); + } + } + } +``` + +(Use whatever fs/path import style validate.ts already has — Read its imports and extend them rather than adding duplicate imports.) + +**3d.** Add the accent check once, near the top of the validation flow (not per-scene): + +```typescript + if (options.transitionAccent !== undefined && !/^#?[0-9a-fA-F]{6}$/.test(options.transitionAccent.trim())) { + errors.push( + `export.transition.accent: "${options.transitionAccent}" is not a 6-digit hex color (e.g. #0ea5e9)`, + ); + } +``` + +**3e.** In `src/cli.ts`'s validate command action, pass the new options from config: `blocksDir: config.blocksDir` and `transitionAccent: config.export.transition?.type === 'shader' ? config.export.transition.accent : undefined` (match how the action already reads `config` — Read it first; if the transition config type needs narrowing, mirror how other CLI code narrows `config.export.transition`). + +- [ ] **Step 4: Run tests + build** + +Run: `npx vitest run tests/hf/ && npm test && npm run build` +Expected: all green (the full suite guards the validate changes against regressions in existing validate tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/validate.ts src/cli.ts tests/ +git -c commit.gpgsign=false commit -m "feat(validate): hf-component install checks + transition accent hex validation" +``` + +--- + +### Task 8: Docs sync + end-to-end network smoke + +**Files:** +- Modify: `README.md` (new "Catalog: argo add" section; config reference gains `blocksDir` + `registry.url`; overlay cue types list gains `hf-component`; script API list gains `applyComponent`/`removeComponent`) +- Modify: `CLAUDE.md` (new `### HyperFrames Catalog (src/hf/)` subsection under Architecture; env var list gains `ARGO_BLOCKS_DIR`; CLI section gains `argo add`) +- Modify: `skills/argo-guide/` (SKILL.md + references — grep for where overlay types and CLI commands are enumerated) + +**Interfaces:** consumes everything; produces nothing downstream — closes Track 2. + +- [ ] **Step 1: Update README** — document, minimally: `argo add ` / `argo add --list`; the install location (`blocks//`, git-tracked); the `hf-component` cue with a manifest example: + +```json +{ "scene": "intro", "text": "…", "overlay": { "type": "hf-component", "name": "vignette", "params": { "--vignette-size": "40%" } } } +``` + +and the script API: + +```ts +import { applyComponent, removeComponent } from '@argo-video/cli'; +await applyComponent(page, 'grain-overlay'); // persists until removed +await removeComponent(page, 'grain-overlay'); +``` + +Note the two caveats: caption-* components install but need word-timing support (future), and injected component scripts can be blocked by strict CSP (warning, not failure). + +- [ ] **Step 2: Update CLAUDE.md and the argo-guide skill** per the Files list. CLAUDE.md's "Env Vars Bridging Config to Playwright" section gains: `ARGO_BLOCKS_DIR — blocks directory for installed hyperframes items (loaded by applyComponent/hf-component cues)`. + +- [ ] **Step 3: Full verification + network smoke** + +Run: `npm run build && npm test` +Expected: all green. + +Then the one allowed network smoke (requires internet; if offline, report DONE_WITH_CONCERNS naming this step): + +```bash +cd "$(mktemp -d)" && node /Users/shreyas/work/rnd/argo/bin/argo.js add --list | head -5 && node /Users/shreyas/work/rnd/argo/bin/argo.js add vignette && test -f blocks/vignette/vignette.html && echo SMOKE-OK +``` + +Expected: item list prints; `SMOKE-OK`. + +- [ ] **Step 4: Commit** + +```bash +git add README.md CLAUDE.md skills/ +git -c commit.gpgsign=false commit -m "docs: argo add command, hf-component cue, blocksDir config" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** command + --list/--json (T3), registry URL config (T3), native-format storage + traversal guards (T2), component adapter with injection fence + instance cleanup (T5), cue + `applyComponent` script API (T5/T6), `ARGO_BLOCKS_DIR` at record.ts with all THREE call sites (T6 — the spec said "both pipeline call sites"; recon found `argo record` in cli.ts:93 is a third `record()` caller, included), validate checks (T7), caption deferral (constraint + README caveat), trust model + param validation (T4/T5), Track-1-deferred accent validation folded into T7. +- **Placeholder scan:** steps that depend on unread code (defineConfig body, withOverlay param name, validate option names, effects.ts catch block) explicitly instruct Read-then-mirror with the target named — bounded lookups, not open TBDs. +- **Type consistency:** `FetchLike`/`RegistryIndexItem`/`InstallResult` names match across T1→T3; `resolveBlocksDir`/`applyComponent`/`removeComponent` match T5→T6; `blocksDir`/`transitionAccent` option names match T7's tests and impl; evaluate-call count in T6's fake-page test (fence + inject + remove = 3 evaluates) matches T5's implementation. From 92944bcdf81a1b597a9d59f32dada45230d19502 Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 11:44:58 -0700 Subject: [PATCH 12/34] feat(hf): hyperframes registry client with injectable fetch --- src/hf/registry-client.ts | 86 ++++++++++++++++++++++++++++++++ tests/hf/registry-client.test.ts | 79 +++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/hf/registry-client.ts create mode 100644 tests/hf/registry-client.test.ts diff --git a/src/hf/registry-client.ts b/src/hf/registry-client.ts new file mode 100644 index 0000000..b106e67 --- /dev/null +++ b/src/hf/registry-client.ts @@ -0,0 +1,86 @@ +/** + * Minimal client for the hyperframes registry layout: + * /registry.json + * ///registry-item.json + * /// + * Fetch is injectable so unit tests never touch the network. + */ + +export const DEFAULT_REGISTRY_URL = + 'https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry'; + +export type FetchLike = ( + url: string, +) => Promise<{ ok: boolean; status: number; text(): Promise }>; + +export interface RegistryIndexItem { + name: string; + type: string; +} + +export interface RegistryItemFile { + path: string; + target?: string; + type?: string; +} + +export interface RegistryItem { + name: string; + type: string; + title?: string; + description?: string; + tags?: string[]; + files: RegistryItemFile[]; + params?: Array<{ key: string; label?: string; type?: string; default?: string }>; +} + +/** Registry item type → URL path segment. Examples are not installable. */ +export function kindFromType(t: string): 'blocks' | 'components' | null { + if (t === 'hyperframes:block') return 'blocks'; + if (t === 'hyperframes:component') return 'components'; + return null; +} + +async function fetchText(url: string, fetchImpl: FetchLike): Promise { + const res = await fetchImpl(url); + if (!res.ok) { + throw new Error(`Registry fetch failed (${res.status}): ${url}`); + } + return res.text(); +} + +export async function fetchRegistryIndex( + registryUrl: string, + fetchImpl: FetchLike = fetch, +): Promise { + const raw = await fetchText(`${registryUrl}/registry.json`, fetchImpl); + const parsed = JSON.parse(raw) as { items?: RegistryIndexItem[] }; + if (!Array.isArray(parsed.items)) { + throw new Error(`Malformed registry index: expected an "items" array at ${registryUrl}/registry.json`); + } + return parsed.items; +} + +export async function fetchRegistryItem( + registryUrl: string, + kind: 'blocks' | 'components', + name: string, + fetchImpl: FetchLike = fetch, +): Promise { + const raw = await fetchText(`${registryUrl}/${kind}/${name}/registry-item.json`, fetchImpl); + const item = JSON.parse(raw) as RegistryItem; + if (!item.name || !Array.isArray(item.files)) { + throw new Error(`Malformed registry-item.json for "${name}": missing name or files`); + } + return item; +} + +export async function fetchItemFile( + registryUrl: string, + kind: 'blocks' | 'components', + name: string, + filePath: string, + fetchImpl: FetchLike = fetch, +): Promise { + return fetchText(`${registryUrl}/${kind}/${name}/${filePath}`, fetchImpl); +} diff --git a/tests/hf/registry-client.test.ts b/tests/hf/registry-client.test.ts new file mode 100644 index 0000000..2d970c3 --- /dev/null +++ b/tests/hf/registry-client.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_REGISTRY_URL, + kindFromType, + fetchRegistryIndex, + fetchRegistryItem, + fetchItemFile, + type FetchLike, +} from '../../src/hf/registry-client.js'; + +function stubFetch(routes: Record): FetchLike { + return async (url: string) => { + const body = routes[url]; + return { + ok: body !== undefined, + status: body !== undefined ? 200 : 404, + text: async () => body ?? 'not found', + }; + }; +} + +const REG = 'https://example.test/registry'; + +describe('registry client', () => { + it('exposes the hyperframes default registry URL', () => { + expect(DEFAULT_REGISTRY_URL).toBe( + 'https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry', + ); + }); + + it('maps registry item types to fetch kinds', () => { + expect(kindFromType('hyperframes:block')).toBe('blocks'); + expect(kindFromType('hyperframes:component')).toBe('components'); + expect(kindFromType('hyperframes:example')).toBe(null); + expect(kindFromType('bogus')).toBe(null); + }); + + it('fetches and parses the registry index', async () => { + const f = stubFetch({ + [`${REG}/registry.json`]: JSON.stringify({ + items: [{ name: 'vignette', type: 'hyperframes:component' }], + }), + }); + const items = await fetchRegistryIndex(REG, f); + expect(items).toEqual([{ name: 'vignette', type: 'hyperframes:component' }]); + }); + + it('fetches and parses a registry item', async () => { + const f = stubFetch({ + [`${REG}/components/vignette/registry-item.json`]: JSON.stringify({ + name: 'vignette', + type: 'hyperframes:component', + files: [{ path: 'vignette.html' }], + }), + }); + const item = await fetchRegistryItem(REG, 'components', 'vignette', f); + expect(item.name).toBe('vignette'); + expect(item.files).toHaveLength(1); + }); + + it('fetches raw item file content', async () => { + const f = stubFetch({ + [`${REG}/components/vignette/vignette.html`]: '
', + }); + await expect(fetchItemFile(REG, 'components', 'vignette', 'vignette.html', f)).resolves.toContain( + 'hf-vignette', + ); + }); + + it('throws a clear error on HTTP failure', async () => { + const f = stubFetch({}); + await expect(fetchRegistryIndex(REG, f)).rejects.toThrow(/registry.*404/i); + }); + + it('throws a clear error on malformed index JSON', async () => { + const f = stubFetch({ [`${REG}/registry.json`]: '{"nope": true}' }); + await expect(fetchRegistryIndex(REG, f)).rejects.toThrow(/items/i); + }); +}); From 17dfeb8f18d04ef20fabf7d3755ca5b2fe96fceb Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 11:52:21 -0700 Subject: [PATCH 13/34] feat(hf): installItem/listItems with path-traversal guards --- src/hf/add.ts | 83 ++++++++++++++++++++++++++++++++++++++++ tests/hf/add.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/hf/add.ts create mode 100644 tests/hf/add.test.ts diff --git a/src/hf/add.ts b/src/hf/add.ts new file mode 100644 index 0000000..4302000 --- /dev/null +++ b/src/hf/add.ts @@ -0,0 +1,83 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + DEFAULT_REGISTRY_URL, + fetchItemFile, + fetchRegistryIndex, + fetchRegistryItem, + kindFromType, + type FetchLike, + type RegistryIndexItem, +} from './registry-client.js'; + +/** Same pattern the CLI uses for demo names — path-traversal guard. */ +const ITEM_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; +/** Item file paths must be flat (no slashes) — e.g. "vignette.html". */ +const ITEM_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +export interface InstallResult { + name: string; + kind: 'blocks' | 'components'; + files: string[]; + targetDir: string; +} + +export async function listItems(opts: { + registryUrl?: string; + fetchImpl?: FetchLike; +}): Promise { + return fetchRegistryIndex(opts.registryUrl ?? DEFAULT_REGISTRY_URL, opts.fetchImpl ?? fetch); +} + +export async function installItem(opts: { + name: string; + blocksDir: string; + registryUrl?: string; + fetchImpl?: FetchLike; +}): Promise { + const { name, blocksDir } = opts; + const registryUrl = opts.registryUrl ?? DEFAULT_REGISTRY_URL; + const fetchImpl = opts.fetchImpl ?? (fetch as FetchLike); + + if (!ITEM_NAME_RE.test(name)) { + throw new Error( + `Invalid item name "${name}" — only letters, digits, "-" and "_" are allowed.`, + ); + } + + const index = await fetchRegistryIndex(registryUrl, fetchImpl); + const entry = index.find((i) => i.name === name); + if (!entry) { + throw new Error( + `Item "${name}" not found in the registry.\nBrowse available items with: argo add --list`, + ); + } + + const kind = kindFromType(entry.type); + if (!kind) { + throw new Error( + `"${name}" is a registry example and examples are not installable via argo add. ` + + `Use the hyperframes CLI (hyperframes init --example ${name}) instead.`, + ); + } + + const item = await fetchRegistryItem(registryUrl, kind, name, fetchImpl); + for (const f of item.files) { + if (!ITEM_FILE_RE.test(f.path)) { + throw new Error(`Unsafe file path in registry-item.json for "${name}": "${f.path}"`); + } + } + + const targetDir = join(blocksDir, name); + mkdirSync(targetDir, { recursive: true }); + + const written: string[] = []; + for (const f of item.files) { + const content = await fetchItemFile(registryUrl, kind, name, f.path, fetchImpl); + writeFileSync(join(targetDir, f.path), content, 'utf-8'); + written.push(f.path); + } + writeFileSync(join(targetDir, 'registry-item.json'), JSON.stringify(item, null, 2), 'utf-8'); + + return { name, kind, files: written, targetDir }; +} diff --git a/tests/hf/add.test.ts b/tests/hf/add.test.ts new file mode 100644 index 0000000..f112900 --- /dev/null +++ b/tests/hf/add.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { installItem, listItems } from '../../src/hf/add.js'; +import type { FetchLike } from '../../src/hf/registry-client.js'; + +const REG = 'https://example.test/registry'; + +function stubFetch(routes: Record): FetchLike { + return async (url: string) => ({ + ok: routes[url] !== undefined, + status: routes[url] !== undefined ? 200 : 404, + text: async () => routes[url] ?? 'not found', + }); +} + +const ROUTES = { + [`${REG}/registry.json`]: JSON.stringify({ + items: [ + { name: 'vignette', type: 'hyperframes:component' }, + { name: 'logo-outro', type: 'hyperframes:block' }, + { name: 'warm-grain', type: 'hyperframes:example' }, + ], + }), + [`${REG}/components/vignette/registry-item.json`]: JSON.stringify({ + name: 'vignette', + type: 'hyperframes:component', + files: [{ path: 'vignette.html' }], + }), + [`${REG}/components/vignette/vignette.html`]: '
', +}; + +describe('installItem', () => { + let tmp: string; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'argo-add-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('installs a component: files + registry-item.json under blocksDir//', async () => { + const result = await installItem({ + name: 'vignette', blocksDir: tmp, registryUrl: REG, fetchImpl: stubFetch(ROUTES), + }); + expect(result.kind).toBe('components'); + expect(existsSync(join(tmp, 'vignette', 'vignette.html'))).toBe(true); + expect(existsSync(join(tmp, 'vignette', 'registry-item.json'))).toBe(true); + expect(readFileSync(join(tmp, 'vignette', 'vignette.html'), 'utf-8')).toContain('hf-vignette'); + expect(result.files).toContain('vignette.html'); + }); + + it('rejects invalid item names (path traversal guard)', async () => { + for (const bad of ['../evil', 'a/b', '.hidden', 'name!']) { + await expect( + installItem({ name: bad, blocksDir: tmp, registryUrl: REG, fetchImpl: stubFetch(ROUTES) }), + ).rejects.toThrow(/invalid item name/i); + } + }); + + it('rejects example items with a helpful error', async () => { + await expect( + installItem({ name: 'warm-grain', blocksDir: tmp, registryUrl: REG, fetchImpl: stubFetch(ROUTES) }), + ).rejects.toThrow(/example.*not installable/i); + }); + + it('rejects unknown items pointing at --list', async () => { + await expect( + installItem({ name: 'nope', blocksDir: tmp, registryUrl: REG, fetchImpl: stubFetch(ROUTES) }), + ).rejects.toThrow(/not found.*--list/is); + }); + + it('rejects item files with unsafe paths', async () => { + const routes = { + ...ROUTES, + [`${REG}/components/vignette/registry-item.json`]: JSON.stringify({ + name: 'vignette', + type: 'hyperframes:component', + files: [{ path: '../../etc/passwd' }], + }), + }; + await expect( + installItem({ name: 'vignette', blocksDir: tmp, registryUrl: REG, fetchImpl: stubFetch(routes) }), + ).rejects.toThrow(/unsafe file path/i); + }); +}); + +describe('listItems', () => { + it('returns the raw index', async () => { + const items = await listItems({ registryUrl: REG, fetchImpl: stubFetch(ROUTES) }); + expect(items).toHaveLength(3); + }); +}); From 3a066554f0cdfd484198bc5fef22abb05aa986e9 Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 12:03:25 -0700 Subject: [PATCH 14/34] feat(cli): argo add command + blocksDir/registry config --- src/cli.ts | 49 ++++++++++++++++++++++++++++++++++ src/config.ts | 5 ++++ tests/config.test.ts | 1 + tests/hf/config-blocks.test.ts | 19 +++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 tests/hf/config-blocks.test.ts diff --git a/src/cli.ts b/src/cli.ts index b9fdc05..d95520f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -371,6 +371,55 @@ export function createProgram(): Command { } }); + program + .command('add [name]') + .description('Install a block or component from the hyperframes registry into blocksDir') + .option('--list', 'list available registry items instead of installing') + .option('--json', 'machine-readable output') + .option('--registry ', 'override the registry URL') + .action(async (name: string | undefined, cmdOpts: { list?: boolean; json?: boolean; registry?: string }) => { + const { installItem, listItems } = await import('./hf/add.js'); + const configPath = program.opts().config; + const config = await loadConfigForDemo(undefined, configPath); + const registryUrl = cmdOpts.registry ?? config.registry?.url; + + if (cmdOpts.list) { + const items = await listItems({ registryUrl }); + if (cmdOpts.json) { + console.log(JSON.stringify(items, null, 2)); + } else { + for (const item of items) { + console.log(`${item.type.replace('hyperframes:', '').padEnd(10)} ${item.name}`); + } + console.log(`\n${items.length} items. Install with: argo add `); + } + return; + } + + if (!name) { + console.error('Usage: argo add (or argo add --list)'); + process.exitCode = 1; + return; + } + + try { + const result = await installItem({ name, blocksDir: config.blocksDir, registryUrl }); + if (cmdOpts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Installed ${result.kind === 'components' ? 'component' : 'block'} "${result.name}" → ${result.targetDir}/`); + for (const f of result.files) console.log(` ${f}`); + if (result.kind === 'components') { + console.log(`\nUse it in a scene: "overlay": { "type": "hf-component", "name": "${result.name}" }`); + console.log(`Or in a demo script: await applyComponent(page, '${result.name}')`); + } + } + } catch (err) { + console.error((err as Error).message); + process.exitCode = 1; + } + }); + program .command('doctor') .description('Check environment: ffmpeg, Playwright, config, assets') diff --git a/src/config.ts b/src/config.ts index 5009d13..e5a5309 100644 --- a/src/config.ts +++ b/src/config.ts @@ -233,6 +233,10 @@ export interface ArgoConfig { baseURL?: string; demosDir: string; outputDir: string; + /** Directory where `argo add` installs hyperframes registry items. Default 'blocks'. */ + blocksDir: string; + /** Registry override for `argo add`. Defaults to the hyperframes GitHub registry. */ + registry?: { url?: string }; tts: TTSConfig; video: VideoConfig; export: ExportConfig; @@ -253,6 +257,7 @@ export type UserConfig = Partial< const DEFAULTS: ArgoConfig = { demosDir: 'demos', outputDir: 'videos', + blocksDir: 'blocks', tts: { defaultVoice: 'af_heart', defaultSpeed: 1.0 }, video: { width: 1920, height: 1080, fps: 30, browser: 'chromium' as BrowserEngine, deviceScaleFactor: 1 }, export: { preset: 'slow', crf: 16 }, diff --git a/tests/config.test.ts b/tests/config.test.ts index 4495592..ccb4d77 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -15,6 +15,7 @@ import { engines } from '../src/tts/engines/index.js'; const DEFAULTS: ArgoConfig = { demosDir: 'demos', outputDir: 'videos', + blocksDir: 'blocks', tts: { defaultVoice: 'af_heart', defaultSpeed: 1.0 }, video: { width: 1920, height: 1080, fps: 30, browser: 'chromium', deviceScaleFactor: 1 }, export: { preset: 'slow', crf: 16 }, diff --git a/tests/hf/config-blocks.test.ts b/tests/hf/config-blocks.test.ts new file mode 100644 index 0000000..3c7011c --- /dev/null +++ b/tests/hf/config-blocks.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { defineConfig } from '../../src/config.js'; + +describe('blocksDir / registry config', () => { + it('defaults blocksDir to "blocks"', () => { + expect(defineConfig({}).blocksDir).toBe('blocks'); + }); + + it('honors a custom blocksDir', () => { + expect(defineConfig({ blocksDir: 'assets/hf' }).blocksDir).toBe('assets/hf'); + }); + + it('passes registry.url through and defaults registry to undefined', () => { + expect(defineConfig({}).registry).toBeUndefined(); + expect(defineConfig({ registry: { url: 'https://x.test/reg' } }).registry?.url).toBe( + 'https://x.test/reg', + ); + }); +}); From 516f6d10a90aae8ca5ca5d961e2ee88d1e45f524 Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 12:11:46 -0700 Subject: [PATCH 15/34] feat(hf): component snippet parser + CSS param safety checks --- src/hf/component.ts | Bin 0 -> 1812 bytes tests/hf/component.test.ts | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/hf/component.ts create mode 100644 tests/hf/component.test.ts diff --git a/src/hf/component.ts b/src/hf/component.ts new file mode 100644 index 0000000000000000000000000000000000000000..459f2d37eeda38d521cd80fa69c814edcfbb7bbf GIT binary patch literal 1812 zcmb7F(T?Ia6zwxV(YsP50>xysZ)FDB?Wj^8cB`ti^H5+rCb@tI$Bt}=89IZuAJH%D zm-ILZ3{_{h>I=bk?zzXu$0upO-=RMJD4l1LwAq*JR!$b|DYHds4VysTsIufh$@f3M zdrM!^l@EK(6a2GfP}WF|sm!S|Oa>9{UWE-+Uapyp4S-UH>QX=!0Q5b~1dE?L&VpQO zrc%1#hbWHq*T>GDq+<%M@_}<9gHXl?sWl~G!|1?{vW*3EfZa;Bwj}fXmatfmGmd16o#rvWnA&KHY+cEc|W#va3u`1%qaZxBmL$&lYcAukNH) zxdbm-^2!2x$;zzBLnrC!dO8KJ54NDvS%?}G*ZWI59eyaS3sC}SEi-=J@3a;Au|u>8 zg&vU)4yczHXWpL=@6JYt6XvQiSv--br{|N7EpFm%vn37L9HZ^{tnpdD z+Hte@lSO}$92@tp=4s=^bgc%I{8r#RZN5M4kH!;05Qv_wb7pzrPW`F52gX_)0ALU!+3>L`b#}z9HHKfWh6~ z$)I*fC9UnfRz8U39+x*pMszst+>3j9x4gf%F2}zJ;Z;#0!;b2B(rU}( z)=Qc6(oE_s=|w)Dlo3sn-A6=JD+C^r*{*0uuf}8QHnGs9rzhGC8+J6t!o@#kCiME^ zqIY!oO5!x5$s=4kyL>!cO%|>EyWU4$tDK(u{ycd?XhA6t4V&>d*fUb>=}zus6P}|~ zHD~z}SenvL_0f@&_mI&9XEnkb=aL}?eT-K!yhEaEMoOy!(Ta$~Hz^tyVOudYB;c^u rh)$6!A2VDSeh30H@mnKWVAy2M52vkuCO-f0shzea#dik!96Emkhe=zV literal 0 HcmV?d00001 diff --git a/tests/hf/component.test.ts b/tests/hf/component.test.ts new file mode 100644 index 0000000..f908b8c --- /dev/null +++ b/tests/hf/component.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { parseComponentSnippet, isSafeCssValue, isSafeCssVarName } from '../../src/hf/component.js'; + +const VIGNETTE_LIKE = ` +
+ +`; + +const SHIMMER_LIKE = ` +`; + +describe('parseComponentSnippet', () => { + it('splits a root-element component into html + css, stripping comments', () => { + const s = parseComponentSnippet(VIGNETTE_LIKE); + expect(s.html).toContain('hf-vignette'); + expect(s.html).not.toContain(' { + const s = parseComponentSnippet(SHIMMER_LIKE); + expect(s.html).toBe(''); + expect(s.css).toContain('.shimmer-sweep-target'); + expect(s.js).toContain('querySelectorAll'); + }); +}); + +describe('param safety', () => { + it('accepts normal CSS values', () => { + for (const ok of ['rgba(0, 0, 0, 0.7)', '45%', '120deg', '#ff8800', 'ellipse', '2.5s']) { + expect(isSafeCssValue(ok), ok).toBe(true); + } + }); + + it('rejects values that could escape a declaration or load resources', () => { + for (const bad of [ + 'red; background: blue', + '} body { display: none', + 'url(https://evil.test/x)', + 'expression(alert(1))', + '`; + +let browser: Browser; +let page: Page; +let tmp: string; + +beforeAll(async () => { + browser = await chromium.launch(); +}, 60_000); +afterAll(async () => { await browser.close(); }); + +beforeEach(async () => { + tmp = mkdtempSync(join(tmpdir(), 'argo-apply-')); + mkdirSync(join(tmp, 'vignette'), { recursive: true }); + writeFileSync(join(tmp, 'vignette', 'vignette.html'), SNIPPET); + page = await browser.newPage(); + await page.setContent('

app

'); +}); +afterEach(async () => { + await page.close(); + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('resolveBlocksDir', () => { + it('prefers explicit, then env, then "blocks"', () => { + const prev = process.env.ARGO_BLOCKS_DIR; + delete process.env.ARGO_BLOCKS_DIR; + expect(resolveBlocksDir('x')).toBe('x'); + expect(resolveBlocksDir()).toBe('blocks'); + process.env.ARGO_BLOCKS_DIR = '/tmp/bd'; + expect(resolveBlocksDir()).toBe('/tmp/bd'); + if (prev === undefined) delete process.env.ARGO_BLOCKS_DIR; else process.env.ARGO_BLOCKS_DIR = prev; + }); +}); + +describe('applyComponent / removeComponent', () => { + it('injects container + style + runs script, applies params, and removes cleanly', async () => { + await applyComponent(page, 'vignette', { + blocksDir: tmp, + params: { '--vignette-size': '30%' }, + }); + + expect(await page.locator('#argo-hf-vignette').count()).toBe(1); + expect(await page.locator('style[data-argo-hf="vignette"]').count()).toBe(1); + expect(await page.evaluate(() => document.documentElement.dataset.hfScriptRan)).toBe('1'); + expect( + await page.evaluate(() => + document.documentElement.style.getPropertyValue('--vignette-size'), + ), + ).toBe('30%'); + + await removeComponent(page, 'vignette'); + expect(await page.locator('#argo-hf-vignette').count()).toBe(0); + expect(await page.locator('style[data-argo-hf="vignette"]').count()).toBe(0); + expect( + await page.evaluate(() => + document.documentElement.style.getPropertyValue('--vignette-size'), + ), + ).toBe(''); + }); + + it('throws for a component that is not installed', async () => { + await expect(applyComponent(page, 'nope', { blocksDir: tmp })).rejects.toThrow(/argo add nope/); + }); + + it('rejects unsafe params before touching the page', async () => { + await expect( + applyComponent(page, 'vignette', { blocksDir: tmp, params: { '--x': 'red; }' } }), + ).rejects.toThrow(/unsafe/i); + await expect( + applyComponent(page, 'vignette', { blocksDir: tmp, params: { 'not-a-var': 'red' } }), + ).rejects.toThrow(/custom property/i); + }); +}, 60_000); From 1a8321249924ccc8edb19c1e62c8060c4d0ec3cb Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 12:27:49 -0700 Subject: [PATCH 18/34] feat(overlays): hf-component cue + ARGO_BLOCKS_DIR env bridge --- src/cli.ts | 1 + src/overlays/index.ts | 18 +++++++++++ src/overlays/templates.ts | 4 +++ src/overlays/types.ts | 16 +++++++++- src/pipeline.ts | 6 ++-- src/record.ts | 3 ++ tests/hf/hf-component-cue.test.ts | 53 +++++++++++++++++++++++++++++++ 7 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 tests/hf/hf-component-cue.test.ts diff --git a/src/cli.ts b/src/cli.ts index d95520f..e9f4edb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,6 +92,7 @@ export function createProgram(): Command { const browser = (cmdOpts.browser as BrowserEngine) ?? config.video.browser; await record(demo, { demosDir: config.demosDir, + blocksDir: config.blocksDir, baseURL, video: { width: config.video.width, height: config.video.height }, browser, diff --git a/src/overlays/index.ts b/src/overlays/index.ts index a8beb51..fce6f0a 100644 --- a/src/overlays/index.ts +++ b/src/overlays/index.ts @@ -8,6 +8,7 @@ import { isGsapMotion } from './gsap-motion.js'; import { runGsapEntrance, runGsapExit, gsapExitWallMs } from './gsap-runtime.js'; import { loadOverlayFromManifest } from './manifest-loader.js'; import { getBlock, isValidBlockName } from '../blocks/index.js'; +import { applyComponent, removeComponent } from '../hf/apply-component.js'; export type { OverlayCue, OverlayManifestEntry, Zone, TemplateType, MotionPreset } from './types.js'; export type { SceneEntry } from './types.js'; @@ -110,6 +111,14 @@ export async function showOverlay( opts = options; } + if (cue.type === 'hf-component') { + // Full-frame component — bypasses zone/theme/template machinery. + await applyComponent(page, cue.name, { params: cue.params }); + await page.waitForTimeout(durationMs); + await removeComponent(page, cue.name); + return; + } + const zone: Zone = cue.placement ?? getConfigDefaultPlacement() ?? 'bottom-center'; const motion = resolveMotion(cue); const theme = await resolveTheme(page, cue, zone, opts?.autoBackground); @@ -179,6 +188,15 @@ export async function withOverlay( opts = options; } + if (cue.type === 'hf-component') { + await applyComponent(page, cue.name, { params: cue.params }); + try { + return await action(); + } finally { + await removeComponent(page, cue.name); + } + } + const zone: Zone = cue.placement ?? getConfigDefaultPlacement() ?? 'bottom-center'; const motion = resolveMotion(cue); const theme = await resolveTheme(page, cue, zone, opts?.autoBackground); diff --git a/src/overlays/templates.ts b/src/overlays/templates.ts index 58206a1..7fac040 100644 --- a/src/overlays/templates.ts +++ b/src/overlays/templates.ts @@ -174,5 +174,9 @@ export function renderTemplate(cue: OverlayCue, theme: BackgroundTheme = 'dark') const merged = { ...block.defaultProps, ...cue.props }; return block.render(merged as never, theme); } + case 'hf-component': + throw new Error( + 'hf-component cues are injected full-frame by showOverlay/applyComponent, not rendered as zone templates.', + ); } } diff --git a/src/overlays/types.ts b/src/overlays/types.ts index 26dfe30..e85e553 100644 --- a/src/overlays/types.ts +++ b/src/overlays/types.ts @@ -98,7 +98,21 @@ export interface CustomBlockCue { autoBackground?: boolean; } -export type OverlayCue = LowerThirdCue | HeadlineCardCue | CalloutCue | ImageCardCue | ArrowCue | CustomBlockCue; +export interface HfComponentCue { + type: 'hf-component'; + /** Installed component name under blocksDir (see `argo add`). */ + name: string; + /** CSS custom property overrides, e.g. { '--vignette-size': '40%' }. */ + params?: Record; + /** Accepted for manifest uniformity but ignored — components are full-frame. */ + placement?: Zone; + /** Accepted for manifest uniformity but ignored — components are full-frame. */ + motion?: MotionPreset; + /** Accepted for manifest uniformity but ignored — components are full-frame. */ + autoBackground?: boolean; +} + +export type OverlayCue = LowerThirdCue | HeadlineCardCue | CalloutCue | ImageCardCue | ArrowCue | CustomBlockCue | HfComponentCue; export type OverlayManifestEntry = OverlayCue & { scene: string; diff --git a/src/pipeline.ts b/src/pipeline.ts index f6ff721..560fe9a 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -77,7 +77,7 @@ export function discoverDemos(demosDir: string): string[] { * Run the pipeline for all demos in the demosDir. */ export async function runBatchPipeline( - config: Pick, + config: Pick, pipelineOpts?: PipelineOptions, ): Promise { const demos = discoverDemos(config.demosDir); @@ -108,7 +108,7 @@ export async function runBatchPipeline( export async function runPipeline( demoName: string, - config: Pick, + config: Pick, pipelineOpts?: PipelineOptions, ): Promise { if (!config.baseURL) { @@ -171,6 +171,7 @@ export async function runPipeline( console.log('🎬 Rolling camera...'); const { timingPath, videoPath } = await record(demoName, { demosDir: config.demosDir, + blocksDir: config.blocksDir, baseURL: config.baseURL, video: { width: config.video.width, height: config.video.height, fps: config.video.fps }, browser: config.video.browser, @@ -526,6 +527,7 @@ export async function runPipeline( console.log('🎬 Rolling camera...'); const variantRecord = await record(demoName, { demosDir: config.demosDir, + blocksDir: config.blocksDir, baseURL: config.baseURL, video: { width: variant.video.width, height: variant.video.height, fps: config.video.fps }, browser: config.video.browser, diff --git a/src/record.ts b/src/record.ts index 629867a..0f4b30d 100644 --- a/src/record.ts +++ b/src/record.ts @@ -7,6 +7,8 @@ import { normalizeDeviceScaleFactor, type BrowserEngine, type ShowActionsConfig export interface RecordOptions { demosDir: string; + /** Directory containing installed hyperframes components (see `argo add`). */ + blocksDir?: string; baseURL: string; video: { width: number; height: number; fps?: number }; browser?: BrowserEngine; @@ -294,6 +296,7 @@ export async function record(demoName: string, options: RecordOptions): Promise< ARGO_SCENE_DURATIONS_PATH: path.resolve(path.join('.argo', demoName, '.scene-durations.json')), ARGO_TRANSCRIPT_PATH: path.resolve(path.join('.argo', demoName, '.scene-transcripts.json')), ARGO_OVERLAYS_PATH: path.resolve(path.join(options.demosDir, `${demoName}.scenes.json`)), + ARGO_BLOCKS_DIR: path.resolve(options.blocksDir ?? 'blocks'), }, }, (error, stdout, stderr) => { clearInterval(progressPoll); diff --git a/tests/hf/hf-component-cue.test.ts b/tests/hf/hf-component-cue.test.ts new file mode 100644 index 0000000..0cdc3cb --- /dev/null +++ b/tests/hf/hf-component-cue.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { OverlayCue } from '../../src/overlays/types.js'; +import { showOverlay } from '../../src/overlays/index.js'; +import { renderTemplate } from '../../src/overlays/templates.js'; + +describe('HfComponentCue', () => { + it('is part of the OverlayCue union at compile time', () => { + const cue: OverlayCue = { type: 'hf-component', name: 'vignette', params: { '--x': '1' } }; + expect(cue.type).toBe('hf-component'); + }); + + it('renderTemplate rejects hf-component cues with a pointer to the right path', () => { + expect(() => renderTemplate({ type: 'hf-component', name: 'vignette' })).toThrow( + /full-frame/i, + ); + }); +}); + +describe('showOverlay dispatch for hf-component', () => { + let tmp: string; + let prevEnv: string | undefined; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'argo-cue-')); + mkdirSync(join(tmp, 'vignette'), { recursive: true }); + writeFileSync(join(tmp, 'vignette', 'vignette.html'), '
'); + prevEnv = process.env.ARGO_BLOCKS_DIR; + process.env.ARGO_BLOCKS_DIR = tmp; + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + if (prevEnv === undefined) delete process.env.ARGO_BLOCKS_DIR; + else process.env.ARGO_BLOCKS_DIR = prevEnv; + }); + + it('applies, waits durationMs, and removes — never touching zone machinery', async () => { + const calls: string[] = []; + const fakePage = { + evaluate: async () => { calls.push('evaluate'); }, + waitForTimeout: async (ms: number) => { calls.push(`wait:${ms}`); }, + }; + await showOverlay( + fakePage as never, + 'intro', + { type: 'hf-component', name: 'vignette' }, + 1200, + ); + // applyComponent issues 2 evaluates (fence + inject), removeComponent 1. + expect(calls).toEqual(['evaluate', 'evaluate', 'wait:1200', 'evaluate']); + }); +}); From 2d83d8df438ca4c211049d2d4751a3cf6ff1744e Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 13:31:34 -0700 Subject: [PATCH 19/34] feat(validate): hf-component install checks + transition accent hex validation Claude-Session: https://claude.ai/code/session_017pvzMoFKE6PbEmbaLT4q4K --- src/cli.ts | 3 + src/validate.ts | 30 +++++++++- tests/hf/validate-hf.test.ts | 103 +++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/hf/validate-hf.test.ts diff --git a/src/cli.ts b/src/cli.ts index e9f4edb..9ebfd01 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -353,6 +353,9 @@ export function createProgram(): Command { demoName: demo, demosDir: config.demosDir, allowRawGsap: config.overlays.allowRawGsap, + blocksDir: config.blocksDir, + transitionAccent: + config.export.transition?.type === 'shader' ? config.export.transition.accent : undefined, }); for (const err of result.errors) { diff --git a/src/validate.ts b/src/validate.ts index fbe5e70..8acb61c 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -7,6 +7,10 @@ export interface ValidateOptions { demosDir: string; /** Mirrors config `overlays.allowRawGsap`. When true, `motion.raw` is accepted. */ allowRawGsap?: boolean; + /** Directory holding installed hyperframes items (config.blocksDir). Default 'blocks'. */ + blocksDir?: string; + /** Mirrors config `export.transition.accent` — validated as 6-digit hex when set. */ + transitionAccent?: string; } export interface ValidateResult { @@ -19,6 +23,13 @@ export async function validateDemo(options: ValidateOptions): Promise { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'argo-validate-hf-')); + mkdirSync(join(tmp, 'demos'), { recursive: true }); + mkdirSync(join(tmp, 'blocks', 'vignette'), { recursive: true }); + writeFileSync(join(tmp, 'blocks', 'vignette', 'vignette.html'), '
'); + writeFileSync( + join(tmp, 'demos', 'd.demo.ts'), + `import { test } from '@argo-video/cli';\ntest('d', async ({ page, narration }) => { narration.mark('intro'); });\n`, + ); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + function writeManifest(overlay: unknown) { + const entry: Record = { scene: 'intro', text: 'hi' }; + if (overlay !== undefined) entry.overlay = overlay; + writeFileSync(join(tmp, 'demos', 'd.scenes.json'), JSON.stringify([entry])); + } + + it('accepts an installed hf-component', async () => { + writeManifest({ type: 'hf-component', name: 'vignette' }); + const result = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + }); + expect(result.errors.filter((e) => e.includes('hf-component'))).toEqual([]); + }); + + it('errors on a missing hf-component with an install hint', async () => { + writeManifest({ type: 'hf-component', name: 'grain-overlay' }); + const result = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + }); + expect(result.errors.some((e) => /grain-overlay[\s\S]*argo add/.test(e))).toBe(true); + }); + + it('errors on an hf-component cue missing "name"', async () => { + writeManifest({ type: 'hf-component' }); + const result = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + }); + expect(result.errors.some((e) => /hf-component.*name/i.test(e))).toBe(true); + }); + + it('errors on an hf-component name with path traversal characters', async () => { + writeManifest({ type: 'hf-component', name: '../evil' }); + const result = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + }); + expect(result.errors.some((e) => /invalid hf-component name/.test(e))).toBe(true); + }); + + it('errors on a malformed transition accent', async () => { + writeManifest(undefined); + const result = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + transitionAccent: 'blue', + }); + expect(result.errors.some((e) => /accent.*hex/i.test(e))).toBe(true); + }); + + it('accepts a valid transition accent', async () => { + writeManifest(undefined); + const result = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + transitionAccent: '#0EA5E9', + }); + expect(result.errors.filter((e) => /accent/i.test(e))).toEqual([]); + }); + + it('accepts a valid transition accent without leading #', async () => { + writeManifest(undefined); + const result = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + transitionAccent: '0ea5e9', + }); + expect(result.errors.filter((e) => /accent/i.test(e))).toEqual([]); + }); +}); From 054136cf1e73e2aa22401a34030b91b3b677f14c Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 15:38:36 -0700 Subject: [PATCH 20/34] docs: argo add command, hf-component cue, blocksDir config Claude-Session: https://claude.ai/code/session_017pvzMoFKE6PbEmbaLT4q4K --- CLAUDE.md | 16 ++++++- README.md | 42 ++++++++++++++++++- skills/argo-guide/SKILL.md | 31 +++++++++++++- .../references/config-and-quality.md | 2 + 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8678b3a..efcee37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,17 @@ Static v1 blocks: `x-post`, `macos-notification`, `yt-lower-third`, `data-chart` Animated blocks (ship with `defaultMotion` using GSAP): `instagram-follow` (pulsing Follow button), `tiktok-follow` (rotating avatar ring + side slide), `reddit-post` (upvote card, simple entrance), `logo-outro` (scale-in end-card), `flowchart` (stacked nodes + arrows revealed with stagger), `app-showcase` (hero card with floating icon loop), `ui-3d-reveal` (perspective tilt-to-flat reveal of a screenshot). These use `BlockDefinition.defaultMotion` — a cue-level `motion` still overrides. Inspired by hyperframes blocks of the same names (Apache-2.0); implementations are original. Selector hooks used by motion loops/staggers: `.argo-ig-follow-btn`, `.argo-tt-ring`, `.argo-app-hero`, `.argo-3d-image`, `.argo-flow-node, .argo-flow-arrow`. -Folder format is designed for a future `argo add ` command (not shipped yet). +These built-in blocks ship inside the package (`src/blocks/`) — distinct from the hyperframes registry items installed into `blocksDir` by `argo add` (see HyperFrames Catalog below). + +### HyperFrames Catalog (`src/hf/`) + +`argo add ` installs blocks/components from the hyperframes registry (default `https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry`, override via `--registry ` or config `registry.url`) into `blocksDir` (config, default `blocks/`, git-tracked). `argo add --list` (+ `--json`) browses the registry. Registry examples (`hyperframes:example`) are not installable. Item files are stored verbatim in native hyperframes format at `blocks//` plus a `registry-item.json` sidecar. Modules: `registry-client.ts` (fetch-injectable client — unit tests never touch the network), `add.ts` (`installItem`/`listItems`, path-traversal guards on item names and file paths), `component.ts` (snippet parser + CSS param validators), `apply-component.ts` (page injection). + +Overlay cue: `{ "type": "hf-component", "name": "vignette", "params": { "--vignette-size": "40%" } }` — full-frame injection (fixed, pointer-events none, high z-index) that bypasses the zone/theme/template machinery; duration comes from `showOverlay`. Script API: `applyComponent(page, name, { params?, blocksDir? })` / `removeComponent(page, name)` exported from the package — persists until removed. `params` are validated CSS custom properties (`--kebab-case` names, conservative value allowlist — no `url()`, `;{}<>`, control chars). Injection uses the same no-op `page.evaluate()` render fence as overlays and the same disposal-error-swallowing pattern as `showConfetti`. + +Trust model: components are trusted-at-install (user ran `argo add`, files are git-reviewable); only runtime `params` are validated. Component ` +`; + +describe('computeBlockHash', () => { + it('is stable and sensitive to every component', () => { + const base = computeBlockHash('', { '--x': '1' }, 1000, 30, 320, 180); + expect(base).toMatch(/^[0-9a-f]{16}$/); + expect(computeBlockHash('', { '--x': '1' }, 1000, 30, 320, 180)).toBe(base); + expect(computeBlockHash('!', { '--x': '1' }, 1000, 30, 320, 180)).not.toBe(base); + expect(computeBlockHash('', { '--x': '2' }, 1000, 30, 320, 180)).not.toBe(base); + expect(computeBlockHash('', { '--x': '1' }, 1500, 30, 320, 180)).not.toBe(base); + expect(computeBlockHash('', undefined, 1000, 30, 320, 180)).toBe( + computeBlockHash('', {}, 1000, 30, 320, 180), + ); + }); +}); + +describe('renderBlockFrames', () => { + let browser: Browser; + let tmp: string; + + beforeAll(async () => { browser = await chromium.launch(); }, 60_000); + afterAll(async () => { await browser.close(); }); + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'argo-blockrender-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('renders N frames by seeking the registered timeline', async () => { + const blockPath = join(tmp, 'fixture.html'); + writeFileSync(blockPath, FIXTURE_BLOCK); + const outDir = join(tmp, 'frames'); + const n = await renderBlockFrames({ + blockHtmlPath: blockPath, outputDir: outDir, + durationMs: 1000, fps: 10, width: 320, height: 180, browser, + }); + expect(n).toBe(10); + const frames = readdirSync(outDir).filter((f) => f.endsWith('.png')).sort(); + expect(frames).toHaveLength(10); + expect(frames[0]).toBe('frame_0000.png'); + expect(frames[9]).toBe('frame_0009.png'); + // first and last frame must differ (bar width animates with seek) + expect(readFileSync(join(outDir, 'frame_0000.png')).equals(readFileSync(join(outDir, 'frame_0009.png')))).toBe(false); + }, 60_000); + + it('applies params as CSS custom properties on the document root', async () => { + const blockPath = join(tmp, 'fx.html'); + writeFileSync(blockPath, FIXTURE_BLOCK.replace('rgb(255, 0, 0)', 'var(--bar-color, rgb(255, 0, 0))')); + const a = join(tmp, 'a'); + const b = join(tmp, 'b'); + await renderBlockFrames({ blockHtmlPath: blockPath, outputDir: a, durationMs: 200, fps: 5, width: 320, height: 180, browser }); + await renderBlockFrames({ blockHtmlPath: blockPath, outputDir: b, durationMs: 200, fps: 5, width: 320, height: 180, params: { '--bar-color': 'rgb(0, 0, 255)' }, browser }); + expect(readFileSync(join(a, 'frame_0000.png')).equals(readFileSync(join(b, 'frame_0000.png')))).toBe(false); + }, 60_000); + + it('fails with an actionable error when no timeline is registered', async () => { + const blockPath = join(tmp, 'no-tl.html'); + writeFileSync(blockPath, '
static
'); + await expect( + renderBlockFrames({ blockHtmlPath: blockPath, outputDir: join(tmp, 'out'), durationMs: 200, fps: 5, width: 320, height: 180, browser }), + ).rejects.toThrow(/__timelines/); + }, 60_000); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/hf/block-render.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the implementation** + +Create `src/hf/block-render.ts`: + +```typescript +import { createHash } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium, type Browser } from 'playwright'; + +/** + * Pre-render a hyperframes block's paused GSAP timeline as a PNG sequence. + * Generalizes the shader-render pattern: headless Chromium, per-frame seek, + * content-addressed cache managed by the caller (renderHfBlocks in Task 5). + * + * The renderer only relies on the hyperframes convention + * `window.__timelines[] = { duration(), pause(), seek(t) }` — it does + * not need GSAP itself, so test fixtures can register plain objects. + */ + +export function computeBlockHash( + blockHtml: string, + params: Record | undefined, + durationMs: number, + fps: number, + width: number, + height: number, +): string { + const parts = [blockHtml, JSON.stringify(params ?? {}), durationMs, fps, width, height].join('|'); + return createHash('sha256').update(parts).digest('hex').slice(0, 16); +} + +export interface RenderBlockFramesOptions { + blockHtmlPath: string; + outputDir: string; + /** Window duration in the video (drives frame count and retiming). */ + durationMs: number; + fps: number; + /** Block canvas dimensions (native block size; compositing scales later). */ + width: number; + height: number; + /** CSS custom property overrides applied on document.documentElement. */ + params?: Record; + /** Pin the final timeline frame instead of linearly retiming. */ + holdLastFrame?: boolean; + /** Reusable browser — pass one across multiple blocks for performance. */ + browser?: Browser; +} + +export async function renderBlockFrames(opts: RenderBlockFramesOptions): Promise { + mkdirSync(opts.outputDir, { recursive: true }); + const N = Math.max(1, Math.round((opts.durationMs * opts.fps) / 1000)); + + const ownsBrowser = !opts.browser; + const browser = opts.browser ?? await chromium.launch({ + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-webgl', + '--ignore-gpu-blacklist', + ], + }); + try { + const page = await browser.newPage({ viewport: { width: opts.width, height: opts.height } }); + try { + await page.goto(pathToFileURL(resolve(opts.blockHtmlPath)).href, { waitUntil: 'load' }); + + if (opts.params) { + await page.evaluate((params) => { + for (const [k, v] of Object.entries(params)) { + document.documentElement.style.setProperty(k, v); + } + }, opts.params); + } + + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + + // Wait for the hyperframes timeline registration convention. + try { + await page.waitForFunction( + () => { + const tls = (window as unknown as { __timelines?: Record }).__timelines; + return !!tls && Object.keys(tls).length > 0; + }, + undefined, + { timeout: 10_000 }, + ); + } catch { + throw new Error( + `Block "${opts.blockHtmlPath}" never registered a timeline on window.__timelines ` + + `(hyperframes blocks register a paused GSAP timeline keyed by composition id).`, + ); + } + + const nativeDurationSec = await page.evaluate(() => { + const tls = (window as unknown as { + __timelines: Record; + }).__timelines; + const tl = tls[Object.keys(tls)[0]]; + tl.pause(); + return tl.duration(); + }); + + const requestedSec = opts.durationMs / 1000; + for (let i = 0; i < N; i++) { + const tVideo = N === 1 ? 0 : (i / (N - 1)) * requestedSec; + const tBlock = opts.holdLastFrame + ? Math.min(tVideo, nativeDurationSec) + : (tVideo * nativeDurationSec) / requestedSec; + await page.evaluate((t) => { + const tls = (window as unknown as { + __timelines: Record; + }).__timelines; + tls[Object.keys(tls)[0]].seek(t); + }, tBlock); + await page.screenshot({ + path: join(opts.outputDir, `frame_${String(i).padStart(4, '0')}.png`), + omitBackground: true, + }); + } + } finally { + await page.close(); + } + } finally { + if (ownsBrowser) await browser.close(); + } + + return N; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/hf/block-render.test.ts` +Expected: PASS (4 tests; launches chromium, ~10-25s) + +- [ ] **Step 5: Commit** + +```bash +git add src/hf/block-render.ts tests/hf/block-render.test.ts +git -c commit.gpgsign=false commit -m "feat(hf): block frame renderer seeking window.__timelines" +``` + +--- + +### Task 2: ffmpeg composite filter builder + +**Files:** +- Create: `src/hf/block-filter.ts` +- Test: `tests/hf/block-filter.test.ts` + +**Interfaces:** +- Consumes: nothing (pure string builder). Mirrors `buildOverlayPngFilters` in `src/overlays/render-to-png.ts:…` — Read that function first and keep the same return contract `{ inputArgs, filterParts, videoSource, nextInput }`. +- Produces (Tasks 3/5 depend on): + - `interface RenderedHfBlock { name: string; pngDir: string; frameCount: number; fps: number; startMs: number; endMs: number; width: number; height: number; fit: 'cover' | { x: number; y: number; scale: number } }` + - `buildHfBlockFilters(blocks: RenderedHfBlock[], baseInputCount: number, videoSourceLabel: string, videoW: number, videoH: number): { inputArgs: string[]; filterParts: string[]; videoSource: string; nextInput: number }` + +- [ ] **Step 1: Write the failing test** + +Create `tests/hf/block-filter.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { buildHfBlockFilters, type RenderedHfBlock } from '../../src/hf/block-filter.js'; + +const BLOCK: RenderedHfBlock = { + name: 'logo-outro', pngDir: '/tmp/cache/abc', frameCount: 60, fps: 30, + startMs: 12_000, endMs: 14_000, width: 1920, height: 1080, fit: 'cover', +}; + +describe('buildHfBlockFilters', () => { + it('returns passthrough for an empty list', () => { + const r = buildHfBlockFilters([], 2, 'v0', 1920, 1080); + expect(r).toEqual({ inputArgs: [], filterParts: [], videoSource: 'v0', nextInput: 2 }); + }); + + it('adds a framerate-pinned image2 sequence input per block', () => { + const r = buildHfBlockFilters([BLOCK], 2, 'v0', 1920, 1080); + expect(r.inputArgs).toEqual([ + '-framerate', '30', '-start_number', '0', '-i', '/tmp/cache/abc/frame_%04d.png', + ]); + expect(r.nextInput).toBe(3); + }); + + it('cover fit: scales to video size, shifts pts to the window start, overlays with enable window', () => { + const r = buildHfBlockFilters([BLOCK], 2, 'v0', 1920, 1080); + expect(r.filterParts).toHaveLength(2); + expect(r.filterParts[0]).toBe('[2:v]format=rgba,scale=1920:1080,setpts=PTS+12.000/TB[hfblk0]'); + expect(r.filterParts[1]).toBe( + "[v0][hfblk0]overlay=0:0:enable='between(t\\,12.000\\,14.000)':format=auto:eof_action=pass[hfb0]", + ); + expect(r.videoSource).toBe('hfb0'); + }); + + it('custom fit: scales by factor and positions at x/y', () => { + const r = buildHfBlockFilters( + [{ ...BLOCK, fit: { x: 100, y: 50, scale: 0.5 } }], 2, 'v0', 1920, 1080, + ); + expect(r.filterParts[0]).toBe('[2:v]format=rgba,scale=960:540,setpts=PTS+12.000/TB[hfblk0]'); + expect(r.filterParts[1]).toContain('overlay=100:50:enable='); + }); + + it('chains multiple blocks through intermediate labels', () => { + const second: RenderedHfBlock = { ...BLOCK, name: 'x-post', pngDir: '/tmp/cache/def', startMs: 2000, endMs: 3000 }; + const r = buildHfBlockFilters([BLOCK, second], 2, 'v0', 1920, 1080); + expect(r.nextInput).toBe(4); + expect(r.filterParts[1]).toContain('[v0][hfblk0]'); + expect(r.filterParts[3]).toContain('[hfb0][hfblk1]'); + expect(r.videoSource).toBe('hfb1'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/hf/block-filter.test.ts` — Expected: FAIL, module not found. + +- [ ] **Step 3: Write the implementation** + +Create `src/hf/block-filter.ts`: + +```typescript +import { join } from 'node:path'; + +/** + * Build ffmpeg inputs + filter_complex parts that composite pre-rendered + * hyperframes block PNG sequences onto the video. Mirrors + * buildOverlayPngFilters (src/overlays/render-to-png.ts) but for image2 + * sequences: the sequence starts at t=0, so setpts shifts it to the cue's + * window before the enable-gated overlay. eof_action=pass keeps cutaway + * semantics — output duration never changes. + */ + +export interface RenderedHfBlock { + name: string; + pngDir: string; + frameCount: number; + fps: number; + startMs: number; + endMs: number; + /** Native block canvas size (the PNG dimensions). */ + width: number; + height: number; + fit: 'cover' | { x: number; y: number; scale: number }; +} + +export function buildHfBlockFilters( + blocks: RenderedHfBlock[], + baseInputCount: number, + videoSourceLabel: string, + videoW: number, + videoH: number, +): { inputArgs: string[]; filterParts: string[]; videoSource: string; nextInput: number } { + if (blocks.length === 0) { + return { inputArgs: [], filterParts: [], videoSource: videoSourceLabel, nextInput: baseInputCount }; + } + + const inputArgs: string[] = []; + const filterParts: string[] = []; + let currentVideo = videoSourceLabel; + let nextInput = baseInputCount; + + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]; + const inputIdx = nextInput++; + inputArgs.push('-framerate', String(b.fps), '-start_number', '0', '-i', join(b.pngDir, 'frame_%04d.png')); + + const startSec = (b.startMs / 1000).toFixed(3); + const endSec = (b.endMs / 1000).toFixed(3); + + let scaleExpr: string; + let x: number; + let y: number; + if (b.fit === 'cover') { + scaleExpr = `scale=${videoW}:${videoH}`; + x = 0; + y = 0; + } else { + scaleExpr = `scale=${Math.round(b.width * b.fit.scale)}:${Math.round(b.height * b.fit.scale)}`; + x = b.fit.x; + y = b.fit.y; + } + + const prepLabel = `hfblk${i}`; + const outLabel = `hfb${i}`; + filterParts.push(`[${inputIdx}:v]format=rgba,${scaleExpr},setpts=PTS+${startSec}/TB[${prepLabel}]`); + filterParts.push( + `[${currentVideo}][${prepLabel}]overlay=${x}:${y}:enable='between(t\\,${startSec}\\,${endSec})':format=auto:eof_action=pass[${outLabel}]`, + ); + currentVideo = outLabel; + } + + return { inputArgs, filterParts, videoSource: currentVideo, nextInput }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/hf/block-filter.test.ts` — Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/hf/block-filter.ts tests/hf/block-filter.test.ts +git -c commit.gpgsign=false commit -m "feat(hf): ffmpeg composite filter builder for block PNG sequences" +``` + +--- + +### Task 3: Export integration (`ExportOptions.hfBlocks`) + +**Files:** +- Modify: `src/export.ts` (options interface at :17; composite step right after the `overlayPngs` block at ~:466; every other place `overlayPngs` is referenced) +- Test: `tests/hf/export-hf-blocks.test.ts` + +**Interfaces:** +- Consumes: `RenderedHfBlock`, `buildHfBlockFilters` (Task 2). +- Produces: `ExportOptions.hfBlocks?: RenderedHfBlock[]` — Task 5's four wiring sites pass it. + +- [ ] **Step 1: Map every `overlayPngs` touchpoint** + +Run: `grep -n "overlayPngs" src/export.ts` — for EACH hit, decide whether `hfBlocks` needs the same treatment and note it in your report: +- the `ExportOptions` field (~:68) → add `hfBlocks?: RenderedHfBlock[]` with JSDoc `/** Pre-rendered hyperframes block PNG sequences composited as cutaway overlays. */` +- the filter-complex gate (~:419, an `||` chain deciding whether a `filter_complex` is required) → add `(options.hfBlocks && options.hfBlocks.length > 0) ||` +- the composite step (~:466-473) → add the equivalent block IMMEDIATELY AFTER it: + +```typescript + const hfBlocks = options.hfBlocks; + if (hfBlocks && hfBlocks.length > 0) { + const hfResult = buildHfBlockFilters(hfBlocks, nextInput, videoSource, outputWidth ?? 1920, outputHeight ?? 1080); + args.push(...hfResult.inputArgs); + filterParts.push(...hfResult.filterParts); + videoSource = hfResult.videoSource; + nextInput = hfResult.nextInput; + } +``` + +(Match the surrounding code's actual local variable names — `nextInput`, `videoSource`, `filterParts`, `args`, `outputWidth`/`outputHeight` are the names visible at the `overlayPngs` step; verify from context when editing.) +- the `hasOverlayPngs` reference (~:763) — Read that region: if it gates `-shortest` or map decisions for overlay PNG inputs, mirror the same gate for hf-block sequence inputs (`hasHfBlocks`). Image2 sequences are finite so they cannot hang the encode, but `-shortest` semantics must not truncate: mirror exactly what overlayPngs does and record your finding in the report. + +Add the import: `import { buildHfBlockFilters, type RenderedHfBlock } from './hf/block-filter.js';` + +- [ ] **Step 2: Write the failing test** + +Create `tests/hf/export-hf-blocks.test.ts` — a compile-time + wiring test (running full ffmpeg here would be slow; Task 5 has the e2e): + +```typescript +import { describe, it, expect } from 'vitest'; +import type { ExportOptions } from '../../src/export.js'; +import type { RenderedHfBlock } from '../../src/hf/block-filter.js'; + +describe('ExportOptions.hfBlocks', () => { + it('accepts pre-rendered block sequences at compile time', () => { + const blocks: RenderedHfBlock[] = [{ + name: 'logo-outro', pngDir: '/tmp/x', frameCount: 60, fps: 30, + startMs: 0, endMs: 2000, width: 1920, height: 1080, fit: 'cover', + }]; + const opts: Partial = { hfBlocks: blocks }; + expect(opts.hfBlocks).toHaveLength(1); + }); +}); +``` + +(If `ExportOptions` is not currently exported from `src/export.ts`, export it — check first; the interface is declared with `export` at :17.) + +- [ ] **Step 3: Run tests, then build** + +Run: `npx vitest run tests/hf/export-hf-blocks.test.ts && npm run build` +Expected: PASS + build exit 0. + +- [ ] **Step 4: Run the export-related test files to catch regressions** + +Run: `npx vitest run tests/export-frame.test.ts tests/freeze.test.ts tests/motion-blur.test.ts` +Expected: PASS (these exercise export filter assembly). + +- [ ] **Step 5: Commit** + +```bash +git add src/export.ts tests/hf/export-hf-blocks.test.ts +git -c commit.gpgsign=false commit -m "feat(export): composite pre-rendered hf-block sequences as cutaway overlays" +``` + +--- + +### Task 4: `hf-block` cue + validate + recording no-op + +**Files:** +- Modify: `src/overlays/types.ts` (add `HfBlockCue`, extend union) +- Modify: `src/overlays/index.ts` (early branch in `showOverlay` and `withOverlay`) +- Modify: `src/overlays/templates.ts` (`renderTemplate` throw case) +- Modify: `src/validate.ts` (validTypes + install check — extend the existing `hf-component` branch to cover both) +- Test: `tests/hf/hf-block-cue.test.ts` + +**Interfaces:** +- Consumes: patterns from Track 2's `hf-component` cue (same files, adjacent code). +- Produces: `HfBlockCue { type: 'hf-block'; name: string; params?: Record; durationMs?: number; fit?: 'cover' | { x: number; y: number; scale: number }; holdLastFrame?: boolean; placement?: Zone; motion?: MotionPreset; autoBackground?: boolean }` in the `OverlayCue` union (the last three optional/ignored, mirroring `HfComponentCue` — Read that interface and match its optional-field pattern exactly). Task 5's cue resolver consumes this shape. + +**Recording semantics:** hf-block is an EXPORT-time effect. During recording, `showOverlay` must do nothing visual but still wait `durationMs` (demo scripts use showOverlay's wait for scene pacing); `withOverlay` just runs its action. + +- [ ] **Step 1: Write the failing test** + +Create `tests/hf/hf-block-cue.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import type { OverlayCue } from '../../src/overlays/types.js'; +import { showOverlay } from '../../src/overlays/index.js'; +import { renderTemplate } from '../../src/overlays/templates.js'; + +describe('HfBlockCue', () => { + it('is part of the OverlayCue union at compile time', () => { + const cue: OverlayCue = { + type: 'hf-block', name: 'logo-outro', fit: 'cover', holdLastFrame: true, durationMs: 2500, + }; + expect(cue.type).toBe('hf-block'); + }); + + it('renderTemplate rejects hf-block cues with a pointer to export-time compositing', () => { + expect(() => renderTemplate({ type: 'hf-block', name: 'logo-outro' })).toThrow(/export/i); + }); + + it('showOverlay is a pacing no-op during recording (waits, no page mutation)', async () => { + const calls: string[] = []; + const fakePage = { + evaluate: async () => { calls.push('evaluate'); }, + waitForTimeout: async (ms: number) => { calls.push(`wait:${ms}`); }, + }; + await showOverlay(fakePage as never, 'outro', { type: 'hf-block', name: 'logo-outro' }, 900); + expect(calls).toEqual(['wait:900']); + }); +}); +``` + +- [ ] **Step 2: Run to verify failures** — TS/type failures + dispatch missing. + +- [ ] **Step 3: Implement** + +**3a.** `src/overlays/types.ts` — add `HfBlockCue` (fields per Interfaces above; copy the optional ignored-field JSDoc style from `HfComponentCue` directly above it) and add `| HfBlockCue` to the union. + +**3b.** `src/overlays/index.ts` — in `showOverlay`, immediately after the existing `hf-component` early branch: + +```typescript + if (cue.type === 'hf-block') { + // Export-time cutaway — nothing is injected during recording, but the + // wait preserves the demo script's scene pacing. + await page.waitForTimeout(durationMs); + return; + } +``` + +In `withOverlay`, after its `hf-component` branch (match the actual action parameter name): + +```typescript + if (cue.type === 'hf-block') { + return await action(); + } +``` + +**3c.** `src/overlays/templates.ts` — add before the switch's closing brace: + +```typescript + case 'hf-block': + throw new Error( + 'hf-block cues are composited at export time (pre-rendered PNG sequences), not rendered as zone templates.', + ); +``` + +**3d.** `src/validate.ts` — extend the existing hf-component validation: the same name-regex + install-file check applies to `hf-block` (blocks install to the same `blocksDir//.html` layout). Refactor minimally: change the condition to `if (ov.type === 'hf-component' || ov.type === 'hf-block')` and use `ov.type` in the error strings so messages stay accurate. Add `'hf-block'` to `validTypes`. Additionally validate that `fit`, when present and not `'cover'`, has numeric `x`, `y`, `scale` fields (push an error naming the scene otherwise). + +- [ ] **Step 4: Extend validate tests** + +Add to `tests/hf/validate-hf.test.ts` (mirror its existing fixture helpers): + +```typescript + it('accepts an installed hf-block and errors on a missing one', async () => { + writeManifest({ type: 'hf-block', name: 'vignette' }); // fixture dir reused — any installed name works + const ok = await validateDemo({ demoName: 'd', demosDir: join(tmp, 'demos'), blocksDir: join(tmp, 'blocks') }); + expect(ok.errors.filter((e) => e.includes('hf-block'))).toEqual([]); + writeManifest({ type: 'hf-block', name: 'logo-outro' }); + const missing = await validateDemo({ demoName: 'd', demosDir: join(tmp, 'demos'), blocksDir: join(tmp, 'blocks') }); + expect(missing.errors.some((e) => /logo-outro.*argo add/.test(e))).toBe(true); + }); + + it('errors on a malformed hf-block fit', async () => { + writeManifest({ type: 'hf-block', name: 'vignette', fit: { x: 1 } }); + const result = await validateDemo({ demoName: 'd', demosDir: join(tmp, 'demos'), blocksDir: join(tmp, 'blocks') }); + expect(result.errors.some((e) => /fit/.test(e))).toBe(true); + }); +``` + +(IMPORTANT: first Read `tests/hf/validate-hf.test.ts` to reuse its actual helper names and option keys — the sketch above assumes `writeManifest`/`demoName`; adjust to what Track 2 actually shipped.) + +- [ ] **Step 5: Run tests + build** + +Run: `npx vitest run tests/hf/ && npx vitest run tests/overlays/ && npm run build` +Expected: all PASS, build exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/overlays/ src/validate.ts tests/hf/ +git -c commit.gpgsign=false commit -m "feat(overlays): hf-block cue (export-time cutaway) + validate checks" +``` + +--- + +### Task 5: Cue resolution + render orchestrator + four-path wiring + +**Files:** +- Modify: `src/hf/block-render.ts` (add `resolveHfBlockCues` + `renderHfBlocks`) +- Modify: `src/pipeline.ts` (primary: collect cues after the manifest parse at ~:286-297, render pre-pass near the shader pre-pass at ~:434, add `hfBlocks` to `exportOptions` at ~:368-452; variants: same around ~:643-663) +- Modify: `src/cli.ts` (export command around :248-266) +- Modify: `src/preview.ts` (export path around :1077-1095) +- Test: `tests/hf/render-hf-blocks.test.ts` + +**Interfaces:** +- Consumes: `renderBlockFrames`, `computeBlockHash` (Task 1), `RenderedHfBlock` (Task 2), `HfBlockCue` (Task 4), `Placement { scene; startMs; endMs }` from `src/tts/align.ts`. +- Produces: + - `interface HfBlockCueResolved { name: string; params?: Record; fit: 'cover' | { x: number; y: number; scale: number }; holdLastFrame: boolean; startMs: number; endMs: number }` + - `resolveHfBlockCues(rawManifest: unknown[], placements: Placement[]): HfBlockCueResolved[]` — for each manifest entry whose `overlay?.type === 'hf-block'` and whose `scene` has a placement: `startMs = placement.startMs`, `endMs = min(placement.startMs + (cue.durationMs ?? (placement.endMs - placement.startMs)), placement.endMs)` … except when `cue.durationMs` exceeds the placement window, allow it to extend to the next placement's startMs (or Infinity for the last scene — the enable window is naturally clipped by video length). Defaults: `fit: 'cover'`, `holdLastFrame: false`. Scenes without placements are skipped with a `console.warn`. + - `renderHfBlocks(opts: { cues: HfBlockCueResolved[]; blocksDir: string; cacheDir: string; fps: number }): Promise` — per cue: read `blocksDir//.html` (throw with `argo add ` hint if missing); read native dimensions from `blocksDir//registry-item.json` (`dimensions.width/height`, fallback 1920×1080); `computeBlockHash` → if `//` already contains the expected `frame_{N-1}.png`, skip rendering (cache hit); else `renderBlockFrames` into it. One shared `chromium.launch(...)` across all cues (launched lazily on first cache miss, closed in `finally`). Returns `RenderedHfBlock[]`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/hf/render-hf-blocks.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveHfBlockCues, renderHfBlocks } from '../../src/hf/block-render.js'; + +const FIXTURE_BLOCK = ` +
+ +`; + +describe('resolveHfBlockCues', () => { + const placements = [ + { scene: 'intro', startMs: 0, endMs: 4000 }, + { scene: 'outro', startMs: 10_000, endMs: 13_000 }, + ]; + + it('maps cues onto placement windows with defaults', () => { + const manifest = [ + { scene: 'intro', overlay: { type: 'lower-third', title: 'x' } }, + { scene: 'outro', overlay: { type: 'hf-block', name: 'logo-outro' } }, + ]; + const cues = resolveHfBlockCues(manifest, placements); + expect(cues).toEqual([{ + name: 'logo-outro', params: undefined, fit: 'cover', holdLastFrame: false, + startMs: 10_000, endMs: 13_000, + }]); + }); + + it('caps cue durationMs at the placement window but lets it extend for the last scene', () => { + const manifest = [ + { scene: 'intro', overlay: { type: 'hf-block', name: 'a', durationMs: 99_000 } }, + { scene: 'outro', overlay: { type: 'hf-block', name: 'b', durationMs: 20_000 } }, + ]; + const cues = resolveHfBlockCues(manifest, placements); + expect(cues[0].endMs).toBe(10_000); // capped at next placement start + expect(cues[1].endMs).toBe(30_000); // last scene: extends; ffmpeg clips at video end + }); + + it('skips scenes without placements', () => { + const cues = resolveHfBlockCues( + [{ scene: 'ghost', overlay: { type: 'hf-block', name: 'a' } }], + placements, + ); + expect(cues).toEqual([]); + }); +}); + +describe('renderHfBlocks', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'argo-renderhf-')); + mkdirSync(join(tmp, 'blocks', 'fx'), { recursive: true }); + writeFileSync(join(tmp, 'blocks', 'fx', 'fx.html'), FIXTURE_BLOCK); + writeFileSync(join(tmp, 'blocks', 'fx', 'registry-item.json'), JSON.stringify({ + name: 'fx', type: 'hyperframes:block', files: [{ path: 'fx.html' }], + dimensions: { width: 320, height: 180 }, + })); + }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('renders, caches, and returns composite-ready records', async () => { + const cues = [{ name: 'fx', params: undefined, fit: 'cover' as const, holdLastFrame: false, startMs: 500, endMs: 1500 }]; + const r1 = await renderHfBlocks({ cues, blocksDir: join(tmp, 'blocks'), cacheDir: join(tmp, 'cache'), fps: 10 }); + expect(r1).toHaveLength(1); + expect(r1[0]).toMatchObject({ name: 'fx', startMs: 500, endMs: 1500, fps: 10, width: 320, height: 180, fit: 'cover', frameCount: 10 }); + expect(readdirSync(r1[0].pngDir).filter((f) => f.endsWith('.png'))).toHaveLength(10); + + // second run: cache hit — same pngDir, no re-render (mtime of first frame unchanged) + const before = readdirSync(r1[0].pngDir).length; + const r2 = await renderHfBlocks({ cues, blocksDir: join(tmp, 'blocks'), cacheDir: join(tmp, 'cache'), fps: 10 }); + expect(r2[0].pngDir).toBe(r1[0].pngDir); + expect(readdirSync(r2[0].pngDir).length).toBe(before); + }, 60_000); + + it('throws with an install hint for a missing block', async () => { + await expect(renderHfBlocks({ + cues: [{ name: 'nope', params: undefined, fit: 'cover', holdLastFrame: false, startMs: 0, endMs: 1000 }], + blocksDir: join(tmp, 'blocks'), cacheDir: join(tmp, 'cache'), fps: 10, + })).rejects.toThrow(/argo add nope/); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — functions not exported. + +- [ ] **Step 3: Implement `resolveHfBlockCues` + `renderHfBlocks`** in `src/hf/block-render.ts` per the Interfaces contract. For the cache-hit check: `existsSync(join(dir, `frame_${String(N - 1).padStart(4, '0')}.png`))` where N is the expected frame count. Import `Placement` type via `import type { Placement } from '../tts/align.js';`. Manifest entries are `unknown` — narrow with the same defensive property checks validate.ts uses (Read its hf-component branch for the idiom). + +- [ ] **Step 4: Run the focused test** — `npx vitest run tests/hf/render-hf-blocks.test.ts` — PASS. + +- [ ] **Step 5: Wire the four export paths** + +At each site, the pattern is: (a) resolve cues from the manifest + the placements array that site passes to `exportVideo`, (b) if non-empty, run `renderHfBlocks` (cacheDir = sibling of the shader cache: pipeline primary `join(argoDir, 'hf-blocks')`, variants `join('.argo', variantSubdir, 'hf-blocks')`, cli `` `.argo/${demo}/hf-blocks` ``, preview `join(demoDir, 'hf-blocks')`), (c) pass the result as `hfBlocks` in the exportVideo options. + +- `src/pipeline.ts` primary: the manifest is already parsed as `rawManifest` (~:290). After `finalPlacements` exists and near the shader pre-pass (~:430), add: + +```typescript + // hf-block cutaways — pre-render installed hyperframes blocks (cache-hit cheap) + const hfBlockCues = resolveHfBlockCues(rawManifest, finalPlacements); + if (hfBlockCues.length > 0) { + exportOptions.hfBlocks = await renderHfBlocks({ + cues: hfBlockCues, + blocksDir: config.blocksDir, + cacheDir: join(argoDir, 'hf-blocks'), + fps: config.video?.fps ?? 30, + }); + } +``` + +(Place AFTER `exportOptions` is constructed at ~:368 and BEFORE `exportVideo(exportOptions)` at ~:452. If `rawManifest`'s type annotation lacks `overlay`, widen its inline type with `overlay?: { type?: string; [k: string]: unknown }`.) + +- `src/pipeline.ts` variants (~:643-663), `src/cli.ts` export (~:248-266), `src/preview.ts` export (~:1077-1095): same pattern with each site's own manifest source, placements array, and cacheDir. Each site already reads the manifest or has a path to it (`config.demosDir`/`demoName`) — Read each site's surrounding code and reuse whatever manifest variable exists, or parse the manifest file the same way pipeline does. In preview, `config.blocksDir` may not be in scope — Read how preview accesses config (it holds an `ec` export-config object and a config module) and thread `blocksDir` the same way `shaderTransition` reached that code. + +- [ ] **Step 6: Full verification** + +Run: `npm run build && npm test` +Expected: build exit 0; full suite green. + +- [ ] **Step 7: Commit** + +```bash +git add src/hf/block-render.ts src/pipeline.ts src/cli.ts src/preview.ts tests/hf/render-hf-blocks.test.ts +git -c commit.gpgsign=false commit -m "feat(pipeline): resolve + pre-render hf-block cues across all export paths" +``` + +--- + +### Task 6: Docs sync + +**Files:** +- Modify: `README.md` (hf-block cue docs in the catalog section Task 8 of Track 2 created; cue example with `durationMs`/`fit`/`holdLastFrame`; note the cutaway semantics + network caveat) +- Modify: `CLAUDE.md` (HyperFrames Catalog section gains the block pre-render paragraph: cache location, `window.__timelines` convention, cutaway semantics, speedRamp limitation; Known Issues gains the speedRamp+hf-block limitation) +- Modify: `skills/argo-guide/` (overlay-type enumerations gain `hf-block`; grep as in prior doc tasks) + +- [ ] **Step 1: Update the three surfaces.** Facts to document (verify each against code): cue shape `{ type: 'hf-block', name, params?, durationMs?, fit?: 'cover' | {x,y,scale}, holdLastFrame? }`; recording-time no-op (pacing wait only); export-time pre-render with cache at `.argo//hf-blocks//`; compositing after camera moves, before frame/watermark; window = scene placement (durationMs caps/extends per resolver rules); blocks need network at export time (GSAP + fonts CDNs); speedRamp incompatibility. + +- [ ] **Step 2: Full verification.** Run: `npm run build && npm test` — green. Then the real-block smoke (network + ~60s; report DONE_WITH_CONCERNS naming this step if offline): + +```bash +cd "$(mktemp -d)" && node /Users/shreyas/work/rnd/argo/bin/argo.js add logo-outro && node -e " +const { renderHfBlocks } = require('/Users/shreyas/work/rnd/argo/dist/hf/block-render.js'); +renderHfBlocks({ cues: [{ name: 'logo-outro', fit: 'cover', holdLastFrame: false, startMs: 0, endMs: 2000 }], blocksDir: 'blocks', cacheDir: '.argo/smoke/hf-blocks', fps: 30 }) + .then(r => console.log('SMOKE-OK', r[0].frameCount, 'frames')); +" +``` + +(If `dist` is ESM so `require` fails, use `node --input-type=module -e` with a dynamic `import()` — adapt as needed; the goal is: real registry block installs, pre-renders 60 frames, prints SMOKE-OK.) + +- [ ] **Step 3: Commit** + +```bash +git add README.md CLAUDE.md skills/ +git -c commit.gpgsign=false commit -m "docs: hf-block cutaway cue, pre-render cache, speedRamp caveat" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** cue shape incl. `fit` default `'cover'` (T4/T5), chromium pre-pass with fonts + `__timelines` wait + `omitBackground` alpha (T1), linear retime + opt-in `holdLastFrame` (T1), content-addressed cache with browser-skip on hit (T5), `overlay` + `enable=between` compositing after transitions/camera moves before frame/watermark (T2/T3), no timeline surgery (constraint + `eof_action=pass`), all four export paths (T5), actionable missing-timeline error (T1, spec's named risk). +- **Deviation from spec, justified:** spec's cue sketch omitted `durationMs`; recording-time overlay duration comes from script calls which don't exist at export, so the manifest cue carries it (defaulting to the placement window). Recorded here so the final review sees it as intentional. +- **Type consistency:** `RenderedHfBlock` fields match between T2 (definition), T3 (export), T5 (producer + test assertions); `HfBlockCueResolved` matches T5's resolver test; `frame_%04d.png` naming matches T1's writer, T2's input args, and T5's cache-hit probe. +- **Placeholder scan:** Read-then-mirror steps name their exact target (overlayPngs touchpoints, validate idiom, preview config threading, withOverlay param name) — bounded lookups. From 89453cbeb271e5a05076e34346677de565d2cd4e Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 16:22:29 -0700 Subject: [PATCH 23/34] feat(hf): block frame renderer seeking window.__timelines --- src/hf/block-render.ts | 128 ++++++++++++++++++++++++++++++++++ tests/hf/block-render.test.ts | 87 +++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 src/hf/block-render.ts create mode 100644 tests/hf/block-render.test.ts diff --git a/src/hf/block-render.ts b/src/hf/block-render.ts new file mode 100644 index 0000000..c98e678 --- /dev/null +++ b/src/hf/block-render.ts @@ -0,0 +1,128 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium, type Browser } from 'playwright'; + +/** + * Pre-render a hyperframes block's paused GSAP timeline as a PNG sequence. + * Generalizes the shader-render pattern: headless Chromium, per-frame seek, + * content-addressed cache managed by the caller (renderHfBlocks in Task 5). + * + * The renderer only relies on the hyperframes convention + * `window.__timelines[] = { duration(), pause(), seek(t) }` — it does + * not need GSAP itself, so test fixtures can register plain objects. + */ + +export function computeBlockHash( + blockHtml: string, + params: Record | undefined, + durationMs: number, + fps: number, + width: number, + height: number, +): string { + const parts = [blockHtml, JSON.stringify(params ?? {}), durationMs, fps, width, height].join('|'); + return createHash('sha256').update(parts).digest('hex').slice(0, 16); +} + +export interface RenderBlockFramesOptions { + blockHtmlPath: string; + outputDir: string; + /** Window duration in the video (drives frame count and retiming). */ + durationMs: number; + fps: number; + /** Block canvas dimensions (native block size; compositing scales later). */ + width: number; + height: number; + /** CSS custom property overrides applied on document.documentElement. */ + params?: Record; + /** Pin the final timeline frame instead of linearly retiming. */ + holdLastFrame?: boolean; + /** Reusable browser — pass one across multiple blocks for performance. */ + browser?: Browser; +} + +export async function renderBlockFrames(opts: RenderBlockFramesOptions): Promise { + mkdirSync(opts.outputDir, { recursive: true }); + const N = Math.max(1, Math.round((opts.durationMs * opts.fps) / 1000)); + + const ownsBrowser = !opts.browser; + const browser = opts.browser ?? await chromium.launch({ + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-webgl', + '--ignore-gpu-blacklist', + ], + }); + try { + const page = await browser.newPage({ viewport: { width: opts.width, height: opts.height } }); + try { + await page.goto(pathToFileURL(resolve(opts.blockHtmlPath)).href, { waitUntil: 'load' }); + + if (opts.params) { + await page.evaluate((params) => { + for (const [k, v] of Object.entries(params)) { + document.documentElement.style.setProperty(k, v); + } + }, opts.params); + } + + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + + // Wait for the hyperframes timeline registration convention. + try { + await page.waitForFunction( + () => { + const tls = (window as unknown as { __timelines?: Record }).__timelines; + return !!tls && Object.keys(tls).length > 0; + }, + undefined, + { timeout: 10_000 }, + ); + } catch { + throw new Error( + `Block "${opts.blockHtmlPath}" never registered a timeline on window.__timelines ` + + `(hyperframes blocks register a paused GSAP timeline keyed by composition id).`, + ); + } + + const nativeDurationSec = await page.evaluate(() => { + const tls = (window as unknown as { + __timelines: Record; + }).__timelines; + const tl = tls[Object.keys(tls)[0]]; + tl.pause(); + return tl.duration(); + }); + + const requestedSec = opts.durationMs / 1000; + for (let i = 0; i < N; i++) { + // Sample each frame at its center instant, not its leading edge — + // otherwise a single-frame window (N=1) always lands on t=0, which + // can coincide with a timeline's initial (pre-animated) state. + const tVideo = (i + 0.5) / opts.fps; + const tBlock = opts.holdLastFrame + ? Math.min(tVideo, nativeDurationSec) + : (tVideo * nativeDurationSec) / requestedSec; + await page.evaluate((t) => { + const tls = (window as unknown as { + __timelines: Record; + }).__timelines; + tls[Object.keys(tls)[0]].seek(t); + }, tBlock); + await page.screenshot({ + path: join(opts.outputDir, `frame_${String(i).padStart(4, '0')}.png`), + omitBackground: true, + }); + } + } finally { + await page.close(); + } + } finally { + if (ownsBrowser) await browser.close(); + } + + return N; +} diff --git a/tests/hf/block-render.test.ts b/tests/hf/block-render.test.ts new file mode 100644 index 0000000..c564a32 --- /dev/null +++ b/tests/hf/block-render.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readdirSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { chromium, type Browser } from 'playwright'; +import { computeBlockHash, renderBlockFrames } from '../../src/hf/block-render.js'; + +// Fixture block: no GSAP, no network. Registers a fake timeline implementing +// the { duration, pause, seek } interface the renderer relies on, and mirrors +// seek progress into the DOM so frames are visually distinguishable. +const FIXTURE_BLOCK = ` + + +
+ +`; + +describe('computeBlockHash', () => { + it('is stable and sensitive to every component', () => { + const base = computeBlockHash('', { '--x': '1' }, 1000, 30, 320, 180); + expect(base).toMatch(/^[0-9a-f]{16}$/); + expect(computeBlockHash('', { '--x': '1' }, 1000, 30, 320, 180)).toBe(base); + expect(computeBlockHash('!', { '--x': '1' }, 1000, 30, 320, 180)).not.toBe(base); + expect(computeBlockHash('', { '--x': '2' }, 1000, 30, 320, 180)).not.toBe(base); + expect(computeBlockHash('', { '--x': '1' }, 1500, 30, 320, 180)).not.toBe(base); + expect(computeBlockHash('', undefined, 1000, 30, 320, 180)).toBe( + computeBlockHash('', {}, 1000, 30, 320, 180), + ); + }); +}); + +describe('renderBlockFrames', () => { + let browser: Browser; + let tmp: string; + + beforeAll(async () => { browser = await chromium.launch(); }, 60_000); + afterAll(async () => { await browser.close(); }); + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'argo-blockrender-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('renders N frames by seeking the registered timeline', async () => { + const blockPath = join(tmp, 'fixture.html'); + writeFileSync(blockPath, FIXTURE_BLOCK); + const outDir = join(tmp, 'frames'); + const n = await renderBlockFrames({ + blockHtmlPath: blockPath, outputDir: outDir, + durationMs: 1000, fps: 10, width: 320, height: 180, browser, + }); + expect(n).toBe(10); + const frames = readdirSync(outDir).filter((f) => f.endsWith('.png')).sort(); + expect(frames).toHaveLength(10); + expect(frames[0]).toBe('frame_0000.png'); + expect(frames[9]).toBe('frame_0009.png'); + // first and last frame must differ (bar width animates with seek) + expect(readFileSync(join(outDir, 'frame_0000.png')).equals(readFileSync(join(outDir, 'frame_0009.png')))).toBe(false); + }, 60_000); + + it('applies params as CSS custom properties on the document root', async () => { + const blockPath = join(tmp, 'fx.html'); + writeFileSync(blockPath, FIXTURE_BLOCK.replace('rgb(255, 0, 0)', 'var(--bar-color, rgb(255, 0, 0))')); + const a = join(tmp, 'a'); + const b = join(tmp, 'b'); + await renderBlockFrames({ blockHtmlPath: blockPath, outputDir: a, durationMs: 200, fps: 5, width: 320, height: 180, browser }); + await renderBlockFrames({ blockHtmlPath: blockPath, outputDir: b, durationMs: 200, fps: 5, width: 320, height: 180, params: { '--bar-color': 'rgb(0, 0, 255)' }, browser }); + expect(readFileSync(join(a, 'frame_0000.png')).equals(readFileSync(join(b, 'frame_0000.png')))).toBe(false); + }, 60_000); + + it('fails with an actionable error when no timeline is registered', async () => { + const blockPath = join(tmp, 'no-tl.html'); + writeFileSync(blockPath, '
static
'); + await expect( + renderBlockFrames({ blockHtmlPath: blockPath, outputDir: join(tmp, 'out'), durationMs: 200, fps: 5, width: 320, height: 180, browser }), + ).rejects.toThrow(/__timelines/); + }, 60_000); +}); From 84f44d56fe250075fb990e956710a2ca7c6f975e Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 16:27:08 -0700 Subject: [PATCH 24/34] fix(hf): edge-inclusive frame sampling with N=1 midpoint fallback --- src/hf/block-render.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hf/block-render.ts b/src/hf/block-render.ts index c98e678..08eb262 100644 --- a/src/hf/block-render.ts +++ b/src/hf/block-render.ts @@ -99,10 +99,11 @@ export async function renderBlockFrames(opts: RenderBlockFramesOptions): Promise const requestedSec = opts.durationMs / 1000; for (let i = 0; i < N; i++) { - // Sample each frame at its center instant, not its leading edge — - // otherwise a single-frame window (N=1) always lands on t=0, which - // can coincide with a timeline's initial (pre-animated) state. - const tVideo = (i + 0.5) / opts.fps; + // Edge-inclusive sampling (matches shader-render.ts): the last frame + // lands exactly at the requested window end so the block's final + // composed state is captured. N === 1 degenerates to t=0 (usually the + // pre-animation state), so a single frame samples the window midpoint. + const tVideo = N === 1 ? requestedSec / 2 : (i / (N - 1)) * requestedSec; const tBlock = opts.holdLastFrame ? Math.min(tVideo, nativeDurationSec) : (tVideo * nativeDurationSec) / requestedSec; From d9ec9211a866cfea623b69dbb06bbfaeb829250a Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 16:29:56 -0700 Subject: [PATCH 25/34] feat(hf): ffmpeg composite filter builder for block PNG sequences --- src/hf/block-filter.ts | 72 +++++++++++++++++++++++++++++++++++ tests/hf/block-filter.test.ts | 49 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/hf/block-filter.ts create mode 100644 tests/hf/block-filter.test.ts diff --git a/src/hf/block-filter.ts b/src/hf/block-filter.ts new file mode 100644 index 0000000..d452260 --- /dev/null +++ b/src/hf/block-filter.ts @@ -0,0 +1,72 @@ +import { join } from 'node:path'; + +/** + * Build ffmpeg inputs + filter_complex parts that composite pre-rendered + * hyperframes block PNG sequences onto the video. Mirrors + * buildOverlayPngFilters (src/overlays/render-to-png.ts) but for image2 + * sequences: the sequence starts at t=0, so setpts shifts it to the cue's + * window before the enable-gated overlay. eof_action=pass keeps cutaway + * semantics — output duration never changes. + */ + +export interface RenderedHfBlock { + name: string; + pngDir: string; + frameCount: number; + fps: number; + startMs: number; + endMs: number; + /** Native block canvas size (the PNG dimensions). */ + width: number; + height: number; + fit: 'cover' | { x: number; y: number; scale: number }; +} + +export function buildHfBlockFilters( + blocks: RenderedHfBlock[], + baseInputCount: number, + videoSourceLabel: string, + videoW: number, + videoH: number, +): { inputArgs: string[]; filterParts: string[]; videoSource: string; nextInput: number } { + if (blocks.length === 0) { + return { inputArgs: [], filterParts: [], videoSource: videoSourceLabel, nextInput: baseInputCount }; + } + + const inputArgs: string[] = []; + const filterParts: string[] = []; + let currentVideo = videoSourceLabel; + let nextInput = baseInputCount; + + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]; + const inputIdx = nextInput++; + inputArgs.push('-framerate', String(b.fps), '-start_number', '0', '-i', join(b.pngDir, 'frame_%04d.png')); + + const startSec = (b.startMs / 1000).toFixed(3); + const endSec = (b.endMs / 1000).toFixed(3); + + let scaleExpr: string; + let x: number; + let y: number; + if (b.fit === 'cover') { + scaleExpr = `scale=${videoW}:${videoH}`; + x = 0; + y = 0; + } else { + scaleExpr = `scale=${Math.round(b.width * b.fit.scale)}:${Math.round(b.height * b.fit.scale)}`; + x = b.fit.x; + y = b.fit.y; + } + + const prepLabel = `hfblk${i}`; + const outLabel = `hfb${i}`; + filterParts.push(`[${inputIdx}:v]format=rgba,${scaleExpr},setpts=PTS+${startSec}/TB[${prepLabel}]`); + filterParts.push( + `[${currentVideo}][${prepLabel}]overlay=${x}:${y}:enable='between(t\\,${startSec}\\,${endSec})':format=auto:eof_action=pass[${outLabel}]`, + ); + currentVideo = outLabel; + } + + return { inputArgs, filterParts, videoSource: currentVideo, nextInput }; +} diff --git a/tests/hf/block-filter.test.ts b/tests/hf/block-filter.test.ts new file mode 100644 index 0000000..b375a24 --- /dev/null +++ b/tests/hf/block-filter.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { buildHfBlockFilters, type RenderedHfBlock } from '../../src/hf/block-filter.js'; + +const BLOCK: RenderedHfBlock = { + name: 'logo-outro', pngDir: '/tmp/cache/abc', frameCount: 60, fps: 30, + startMs: 12_000, endMs: 14_000, width: 1920, height: 1080, fit: 'cover', +}; + +describe('buildHfBlockFilters', () => { + it('returns passthrough for an empty list', () => { + const r = buildHfBlockFilters([], 2, 'v0', 1920, 1080); + expect(r).toEqual({ inputArgs: [], filterParts: [], videoSource: 'v0', nextInput: 2 }); + }); + + it('adds a framerate-pinned image2 sequence input per block', () => { + const r = buildHfBlockFilters([BLOCK], 2, 'v0', 1920, 1080); + expect(r.inputArgs).toEqual([ + '-framerate', '30', '-start_number', '0', '-i', '/tmp/cache/abc/frame_%04d.png', + ]); + expect(r.nextInput).toBe(3); + }); + + it('cover fit: scales to video size, shifts pts to the window start, overlays with enable window', () => { + const r = buildHfBlockFilters([BLOCK], 2, 'v0', 1920, 1080); + expect(r.filterParts).toHaveLength(2); + expect(r.filterParts[0]).toBe('[2:v]format=rgba,scale=1920:1080,setpts=PTS+12.000/TB[hfblk0]'); + expect(r.filterParts[1]).toBe( + "[v0][hfblk0]overlay=0:0:enable='between(t\\,12.000\\,14.000)':format=auto:eof_action=pass[hfb0]", + ); + expect(r.videoSource).toBe('hfb0'); + }); + + it('custom fit: scales by factor and positions at x/y', () => { + const r = buildHfBlockFilters( + [{ ...BLOCK, fit: { x: 100, y: 50, scale: 0.5 } }], 2, 'v0', 1920, 1080, + ); + expect(r.filterParts[0]).toBe('[2:v]format=rgba,scale=960:540,setpts=PTS+12.000/TB[hfblk0]'); + expect(r.filterParts[1]).toContain('overlay=100:50:enable='); + }); + + it('chains multiple blocks through intermediate labels', () => { + const second: RenderedHfBlock = { ...BLOCK, name: 'x-post', pngDir: '/tmp/cache/def', startMs: 2000, endMs: 3000 }; + const r = buildHfBlockFilters([BLOCK, second], 2, 'v0', 1920, 1080); + expect(r.nextInput).toBe(4); + expect(r.filterParts[1]).toContain('[v0][hfblk0]'); + expect(r.filterParts[3]).toContain('[hfb0][hfblk1]'); + expect(r.videoSource).toBe('hfb1'); + }); +}); From 5ca1d70c118bc52e3ab471b5f8da4dc57d98e716 Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 16:34:57 -0700 Subject: [PATCH 26/34] feat(export): composite pre-rendered hf-block sequences as cutaway overlays --- src/export.ts | 28 +++++++++++++++++++++++++--- tests/hf/export-hf-blocks.test.ts | 14 ++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tests/hf/export-hf-blocks.test.ts diff --git a/src/export.ts b/src/export.ts index 7d2315b..e0fc2be 100644 --- a/src/export.ts +++ b/src/export.ts @@ -11,6 +11,7 @@ import { buildCameraMoveFilter, buildMotionBlurFilter, type CameraMove } from '. import { buildFreezeFilter, type ResolvedFreeze } from './freeze.js'; import { getVideoFrameRate } from './media.js'; import { buildOverlayPngFilters, isImportedVideo, type RenderedOverlayPng } from './overlays/render-to-png.js'; +import { buildHfBlockFilters, type RenderedHfBlock } from './hf/block-filter.js'; import { buildFrameFilter } from './frame.js'; import { getGpuEncoderName, resolveEncoder, type GpuEncoder } from './gpu-encoder.js'; @@ -66,6 +67,8 @@ export interface ExportOptions { watermark?: WatermarkConfig; /** Pre-rendered overlay PNGs to composite onto the video (for imported videos). */ overlayPngs?: RenderedOverlayPng[]; + /** Pre-rendered hyperframes block PNG sequences composited as cutaway overlays. */ + hfBlocks?: RenderedHfBlock[]; /** Apply contrast-adaptive sharpening (CAS) to restore text crispness. * true = strength 0.5. { strength: 0.0-1.0 } to tune. */ sharpen?: boolean | { strength: number }; @@ -412,11 +415,13 @@ export async function exportVideo(options: ExportOptions): Promise { // filter_complex — otherwise ffmpeg rejects with "Option vf cannot be // applied to input url" because the new `-i` lands after the `-vf`. Covers // frame (adds PNG input), watermark (adds PNG input), overlayPngs (adds - // PNG inputs), cameraMoves (filter_complex only), and sharpen (which - // routes via filter_complex once other filters are present). + // PNG inputs), hfBlocks (adds image2 sequence inputs), cameraMoves + // (filter_complex only), and sharpen (which routes via filter_complex + // once other filters are present). const downstreamWillUseFilterComplex = (options.cameraMoves && options.cameraMoves.length > 0) || (options.overlayPngs && options.overlayPngs.length > 0) || + (options.hfBlocks && options.hfBlocks.length > 0) || Boolean(options.frame) || Boolean(options.watermark && options.watermark.src && existsSync(options.watermark.src)) || Boolean(options.sharpen); @@ -472,6 +477,18 @@ export async function exportVideo(options: ExportOptions): Promise { nextInput = ovlResult.nextInput; } + // Pre-rendered hyperframes block PNG sequences — composited immediately after + // overlay PNGs (same layer priority: after transitions/camera moves, before + // frame/watermark) as cutaway overlays with an `enable`-gated timeline window. + const hfBlocks = options.hfBlocks; + if (hfBlocks && hfBlocks.length > 0) { + const hfResult = buildHfBlockFilters(hfBlocks, nextInput, videoSource, outputWidth ?? 1920, outputHeight ?? 1080); + args.push(...hfResult.inputArgs); + filterParts.push(...hfResult.filterParts); + videoSource = hfResult.videoSource; + nextInput = hfResult.nextInput; + } + // Frame effect — padding, rounded corners, shadow, background // Applied AFTER all content processing, BEFORE watermark let frame = options.frame; @@ -758,11 +775,16 @@ export async function exportVideo(options: ExportOptions): Promise { // Skip -shortest when: // - Freeze-frame holds extend the video beyond the audio // - Overlay PNGs are present (imported videos where audio may be shorter than video) + // - hf-block PNG sequences are present (same reasoning as overlay PNGs — these + // are finite image2 sequences with eof_action=pass, so they can't hang the + // encode, but -shortest could still truncate the video against a shorter + // audio track when they're the reason overlayPngs would otherwise be absent) // - Imported videos have narration shorter than the full source video const hasFreezes = freezeSpecs && freezeSpecs.length > 0; const hasOverlayPngs = options.overlayPngs && options.overlayPngs.length > 0; + const hasHfBlocks = options.hfBlocks && options.hfBlocks.length > 0; const importedNarrationVideo = importedVideo && hasAudio; - if (!hasFreezes && !hasOverlayPngs && !importedNarrationVideo) { + if (!hasFreezes && !hasOverlayPngs && !hasHfBlocks && !importedNarrationVideo) { args.push('-shortest'); } } diff --git a/tests/hf/export-hf-blocks.test.ts b/tests/hf/export-hf-blocks.test.ts new file mode 100644 index 0000000..b839c3e --- /dev/null +++ b/tests/hf/export-hf-blocks.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import type { ExportOptions } from '../../src/export.js'; +import type { RenderedHfBlock } from '../../src/hf/block-filter.js'; + +describe('ExportOptions.hfBlocks', () => { + it('accepts pre-rendered block sequences at compile time', () => { + const blocks: RenderedHfBlock[] = [{ + name: 'logo-outro', pngDir: '/tmp/x', frameCount: 60, fps: 30, + startMs: 0, endMs: 2000, width: 1920, height: 1080, fit: 'cover', + }]; + const opts: Partial = { hfBlocks: blocks }; + expect(opts.hfBlocks).toHaveLength(1); + }); +}); From ca85f00d35f94fde421f83d0ea83eae06aa300e6 Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 16:42:27 -0700 Subject: [PATCH 27/34] feat(overlays): hf-block cue (export-time cutaway) + validate checks --- src/overlays/index.ts | 11 +++++++++++ src/overlays/templates.ts | 4 ++++ src/overlays/types.ts | 22 +++++++++++++++++++++- src/validate.ts | 25 +++++++++++++++++++------ tests/hf/hf-block-cue.test.ts | 27 +++++++++++++++++++++++++++ tests/hf/validate-hf.test.ts | 28 ++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 tests/hf/hf-block-cue.test.ts diff --git a/src/overlays/index.ts b/src/overlays/index.ts index fce6f0a..c1eb40c 100644 --- a/src/overlays/index.ts +++ b/src/overlays/index.ts @@ -119,6 +119,13 @@ export async function showOverlay( return; } + if (cue.type === 'hf-block') { + // Export-time cutaway — nothing is injected during recording, but the + // wait preserves the demo script's scene pacing. + await page.waitForTimeout(durationMs); + return; + } + const zone: Zone = cue.placement ?? getConfigDefaultPlacement() ?? 'bottom-center'; const motion = resolveMotion(cue); const theme = await resolveTheme(page, cue, zone, opts?.autoBackground); @@ -197,6 +204,10 @@ export async function withOverlay( } } + if (cue.type === 'hf-block') { + return await action(); + } + const zone: Zone = cue.placement ?? getConfigDefaultPlacement() ?? 'bottom-center'; const motion = resolveMotion(cue); const theme = await resolveTheme(page, cue, zone, opts?.autoBackground); diff --git a/src/overlays/templates.ts b/src/overlays/templates.ts index 7fac040..c5161fa 100644 --- a/src/overlays/templates.ts +++ b/src/overlays/templates.ts @@ -178,5 +178,9 @@ export function renderTemplate(cue: OverlayCue, theme: BackgroundTheme = 'dark') throw new Error( 'hf-component cues are injected full-frame by showOverlay/applyComponent, not rendered as zone templates.', ); + case 'hf-block': + throw new Error( + 'hf-block cues are composited at export time (pre-rendered PNG sequences), not rendered as zone templates.', + ); } } diff --git a/src/overlays/types.ts b/src/overlays/types.ts index e85e553..00eac61 100644 --- a/src/overlays/types.ts +++ b/src/overlays/types.ts @@ -112,7 +112,27 @@ export interface HfComponentCue { autoBackground?: boolean; } -export type OverlayCue = LowerThirdCue | HeadlineCardCue | CalloutCue | ImageCardCue | ArrowCue | CustomBlockCue | HfComponentCue; +export interface HfBlockCue { + type: 'hf-block'; + /** Installed block name under blocksDir (see `argo add`). */ + name: string; + /** Block-specific param overrides passed through to the pre-render step. */ + params?: Record; + /** Duration of the export-time cutaway in ms. */ + durationMs?: number; + /** How the rendered block frame is fit into the timeline. Default: 'cover'. */ + fit?: 'cover' | { x: number; y: number; scale: number }; + /** Hold the last rendered frame instead of looping/collapsing. */ + holdLastFrame?: boolean; + /** Accepted for manifest uniformity but ignored — blocks are composited at export time. */ + placement?: Zone; + /** Accepted for manifest uniformity but ignored — blocks are composited at export time. */ + motion?: MotionPreset; + /** Accepted for manifest uniformity but ignored — blocks are composited at export time. */ + autoBackground?: boolean; +} + +export type OverlayCue = LowerThirdCue | HeadlineCardCue | CalloutCue | ImageCardCue | ArrowCue | CustomBlockCue | HfComponentCue | HfBlockCue; export type OverlayManifestEntry = OverlayCue & { scene: string; diff --git a/src/validate.ts b/src/validate.ts index 8acb61c..db8cda5 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -67,7 +67,7 @@ export async function validateDemo(options: ValidateOptions): Promise/.html layout. + if (ov.type === 'hf-component' || ov.type === 'hf-block') { if (!ov.name || typeof ov.name !== 'string') { - errors.push(`Scene "${entry.scene}" overlay: hf-component requires a "name" field`); + errors.push(`Scene "${entry.scene}" overlay: ${ov.type} requires a "name" field`); } else if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(ov.name)) { - errors.push(`Scene "${entry.scene}" overlay: invalid hf-component name "${ov.name}"`); + errors.push(`Scene "${entry.scene}" overlay: invalid ${ov.type} name "${ov.name}"`); } else { const blocksDir = options.blocksDir ?? 'blocks'; const componentFile = join(blocksDir, ov.name, `${ov.name}.html`); if (!existsSync(componentFile)) { errors.push( - `Scene "${entry.scene}" overlay: hf-component "${ov.name}" is not installed ` + + `Scene "${entry.scene}" overlay: ${ov.type} "${ov.name}" is not installed ` + `(missing ${componentFile}). Run: argo add ${ov.name}`, ); } } } + // Validate hf-block fit shape + if (ov.type === 'hf-block' && ov.fit !== undefined && ov.fit !== 'cover') { + const fit = ov.fit; + if ( + typeof fit !== 'object' || fit === null || + typeof fit.x !== 'number' || typeof fit.y !== 'number' || typeof fit.scale !== 'number' + ) { + errors.push( + `Scene "${entry.scene}" overlay: hf-block "fit" must be 'cover' or { x, y, scale } with numeric fields`, + ); + } + } } } diff --git a/tests/hf/hf-block-cue.test.ts b/tests/hf/hf-block-cue.test.ts new file mode 100644 index 0000000..6a8d0b5 --- /dev/null +++ b/tests/hf/hf-block-cue.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import type { OverlayCue } from '../../src/overlays/types.js'; +import { showOverlay } from '../../src/overlays/index.js'; +import { renderTemplate } from '../../src/overlays/templates.js'; + +describe('HfBlockCue', () => { + it('is part of the OverlayCue union at compile time', () => { + const cue: OverlayCue = { + type: 'hf-block', name: 'logo-outro', fit: 'cover', holdLastFrame: true, durationMs: 2500, + }; + expect(cue.type).toBe('hf-block'); + }); + + it('renderTemplate rejects hf-block cues with a pointer to export-time compositing', () => { + expect(() => renderTemplate({ type: 'hf-block', name: 'logo-outro' })).toThrow(/export/i); + }); + + it('showOverlay is a pacing no-op during recording (waits, no page mutation)', async () => { + const calls: string[] = []; + const fakePage = { + evaluate: async () => { calls.push('evaluate'); }, + waitForTimeout: async (ms: number) => { calls.push(`wait:${ms}`); }, + }; + await showOverlay(fakePage as never, 'outro', { type: 'hf-block', name: 'logo-outro' }, 900); + expect(calls).toEqual(['wait:900']); + }); +}); diff --git a/tests/hf/validate-hf.test.ts b/tests/hf/validate-hf.test.ts index f17357d..b905c02 100644 --- a/tests/hf/validate-hf.test.ts +++ b/tests/hf/validate-hf.test.ts @@ -90,6 +90,34 @@ describe('validate: hf-component + accent', () => { expect(result.errors.filter((e) => /accent/i.test(e))).toEqual([]); }); + it('accepts an installed hf-block and errors on a missing one', async () => { + writeManifest({ type: 'hf-block', name: 'vignette' }); // fixture dir reused — any installed name works + const ok = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + }); + expect(ok.errors.filter((e) => e.includes('hf-block'))).toEqual([]); + + writeManifest({ type: 'hf-block', name: 'logo-outro' }); + const missing = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + }); + expect(missing.errors.some((e) => /logo-outro[\s\S]*argo add/.test(e))).toBe(true); + }); + + it('errors on a malformed hf-block fit', async () => { + writeManifest({ type: 'hf-block', name: 'vignette', fit: { x: 1 } }); + const result = await validateDemo({ + demoName: 'd', + demosDir: join(tmp, 'demos'), + blocksDir: join(tmp, 'blocks'), + }); + expect(result.errors.some((e) => /fit/.test(e))).toBe(true); + }); + it('accepts a valid transition accent without leading #', async () => { writeManifest(undefined); const result = await validateDemo({ From 76f38190b59c6d40c2e81bd8e91e0c6de331fdad Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 16:52:56 -0700 Subject: [PATCH 28/34] feat(pipeline): resolve + pre-render hf-block cues across all export paths Adds resolveHfBlockCues (maps hf-block overlay cues onto scene placement windows, allowing durationMs to extend past the scene into the following gap) and renderHfBlocks (per-cue cache-hit-cheap PNG sequence rendering sharing a single lazily-launched chromium instance) to src/hf/block-render.ts. Wires all four export call sites that must stay in sync per the repo's four-path invariant: pipeline.ts primary + variants, cli.ts export command, and preview.ts export (threading blocksDir through PreviewExportConfig from both cli.ts preview/dashboard call sites, mirroring how shaderTransition config already reaches that code). --- src/cli.ts | 19 ++++ src/hf/block-render.ts | 175 +++++++++++++++++++++++++++++- src/pipeline.ts | 33 +++++- src/preview.ts | 16 +++ tests/hf/render-hf-blocks.test.ts | 82 ++++++++++++++ 5 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 tests/hf/render-hf-blocks.test.ts diff --git a/src/cli.ts b/src/cli.ts index 9ebfd01..b133c8b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ import { applySpeedRampToTimeline, type Segment, type SceneSpeedMap } from './sp import { scaleCameraMoves, shiftCameraMoves, type CameraMove } from './camera-move.js'; import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs, type FreezeSpec } from './freeze.js'; import { renderShaderTransitions } from './transitions/shader-render.js'; +import { resolveHfBlockCues, renderHfBlocks } from './hf/block-render.js'; import type { Placement } from './tts/align.js'; function validateDemoName(name: string): string { @@ -263,6 +264,21 @@ export function createProgram(): Command { })); } + // hf-block cutaways — pre-render installed hyperframes blocks (cache-hit cheap) + let hfBlocks: import('./hf/block-filter.js').RenderedHfBlock[] | undefined; + if (placements && placements.length > 0 && existsSync(manifestPath)) { + const hfManifestEntries = readScenesManifest(manifestPath); + const hfBlockCues = resolveHfBlockCues(hfManifestEntries, placements); + if (hfBlockCues.length > 0) { + hfBlocks = await renderHfBlocks({ + cues: hfBlockCues, + blocksDir: config.blocksDir, + cacheDir: `.argo/${demo}/hf-blocks`, + fps: config.video?.fps ?? 30, + }); + } + } + await exportVideo({ demoName: demo, argoDir: '.argo', @@ -305,6 +321,7 @@ export function createProgram(): Command { freezeSpecs: resolvedFreezes.length > 0 ? resolvedFreezes : undefined, overlayPngs, shaderTransitions, + hfBlocks, encoder: config.export.encoder, encoderDefault: 'cpu', }); @@ -515,6 +532,7 @@ export function createProgram(): Command { sharpen: config.export.sharpen, frame: config.export.frame, motionBlur: config.export.motionBlur, + blocksDir: config.blocksDir, }, }); console.log(`\nArgo Dashboard running at: ${url}`); @@ -554,6 +572,7 @@ export function createProgram(): Command { sharpen: config.export.sharpen, frame: config.export.frame, motionBlur: config.export.motionBlur, + blocksDir: config.blocksDir, }, }); console.log(`\nArgo Preview running at: ${url}`); diff --git a/src/hf/block-render.ts b/src/hf/block-render.ts index 08eb262..0fcda43 100644 --- a/src/hf/block-render.ts +++ b/src/hf/block-render.ts @@ -1,8 +1,10 @@ import { createHash } from 'node:crypto'; -import { mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { chromium, type Browser } from 'playwright'; +import type { Placement } from '../tts/align.js'; +import type { RenderedHfBlock } from './block-filter.js'; /** * Pre-render a hyperframes block's paused GSAP timeline as a PNG sequence. @@ -127,3 +129,174 @@ export async function renderBlockFrames(opts: RenderBlockFramesOptions): Promise return N; } + +/** + * A resolved `hf-block` overlay cue: manifest `overlay.type === 'hf-block'` + * data merged with the placement window (scene timing) it plays over. + */ +export interface HfBlockCueResolved { + name: string; + params?: Record; + fit: 'cover' | { x: number; y: number; scale: number }; + holdLastFrame: boolean; + startMs: number; + endMs: number; +} + +/** + * Extract `hf-block` overlay cues from a raw (untyped) scenes manifest and + * anchor each to its scene's placement window on the final export timeline. + * + * `endMs` is `placement.startMs + (cue.durationMs ?? windowMs)`, clamped to + * the placement's own `endMs` — unless the requested duration overshoots the + * window, in which case the cue is allowed to run into the gap after the + * scene (capped at the next placement's `startMs`, or left uncapped for the + * last scene — ffmpeg's `between()` enable window is naturally clipped by + * the video's total duration). + * + * Scenes without a matching placement (e.g. a scene name typo, or a scene + * that produced no timeline placement) are skipped with a warning rather + * than throwing — hf-block cutaways are best-effort like overlays/subtitles. + */ +export function resolveHfBlockCues(rawManifest: unknown[], placements: Placement[]): HfBlockCueResolved[] { + const placementByScene = new Map(); + for (const p of placements) placementByScene.set(p.scene, p); + const sortedPlacements = [...placements].sort((a, b) => a.startMs - b.startMs); + + const cues: HfBlockCueResolved[] = []; + for (const entry of rawManifest) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as { scene?: unknown; overlay?: unknown }; + if (typeof e.scene !== 'string') continue; + + const overlay = e.overlay; + if (!overlay || typeof overlay !== 'object') continue; + const ov = overlay as { + type?: unknown; + name?: unknown; + durationMs?: unknown; + params?: unknown; + fit?: unknown; + holdLastFrame?: unknown; + }; + if (ov.type !== 'hf-block' || typeof ov.name !== 'string') continue; + + const placement = placementByScene.get(e.scene); + if (!placement) { + console.warn(`hf-block cue for scene "${e.scene}" has no placement on the timeline — skipping.`); + continue; + } + + const windowMs = placement.endMs - placement.startMs; + const requestedMs = typeof ov.durationMs === 'number' ? ov.durationMs : windowMs; + const naiveEnd = placement.startMs + requestedMs; + + let endMs: number; + if (naiveEnd > placement.endMs) { + const idx = sortedPlacements.findIndex((p) => p === placement); + const next = idx >= 0 ? sortedPlacements[idx + 1] : undefined; + endMs = next ? Math.min(naiveEnd, next.startMs) : naiveEnd; + } else { + endMs = naiveEnd; + } + + const params = ov.params && typeof ov.params === 'object' + ? (ov.params as Record) + : undefined; + const fit: HfBlockCueResolved['fit'] = ov.fit && ov.fit !== 'cover' + ? (ov.fit as { x: number; y: number; scale: number }) + : 'cover'; + const holdLastFrame = typeof ov.holdLastFrame === 'boolean' ? ov.holdLastFrame : false; + + cues.push({ name: ov.name, params, fit, holdLastFrame, startMs: placement.startMs, endMs }); + } + + return cues; +} + +export interface RenderHfBlocksOptions { + cues: HfBlockCueResolved[]; + blocksDir: string; + cacheDir: string; + fps: number; +} + +interface RegistryItemMeta { + dimensions?: { width?: number; height?: number }; +} + +/** + * Render (or reuse from cache) the PNG sequence for each resolved hf-block + * cue. One `chromium` instance is shared across cache misses (launched + * lazily, closed in `finally`) — mirrors the shader-render pre-pass pattern. + */ +export async function renderHfBlocks(opts: RenderHfBlocksOptions): Promise { + const results: RenderedHfBlock[] = []; + let browser: Browser | undefined; + + try { + for (const cue of opts.cues) { + const blockDir = join(opts.blocksDir, cue.name); + const blockHtmlPath = join(blockDir, `${cue.name}.html`); + if (!existsSync(blockHtmlPath)) { + throw new Error( + `hf-block "${cue.name}" is not installed (missing ${blockHtmlPath}). Run: argo add ${cue.name}`, + ); + } + + let width = 1920; + let height = 1080; + try { + const registry = JSON.parse( + readFileSync(join(blockDir, 'registry-item.json'), 'utf-8'), + ) as RegistryItemMeta; + if (typeof registry.dimensions?.width === 'number') width = registry.dimensions.width; + if (typeof registry.dimensions?.height === 'number') height = registry.dimensions.height; + } catch { + // Missing/malformed registry-item.json — fall back to 1920x1080. + } + + const durationMs = cue.endMs - cue.startMs; + const frameCount = Math.max(1, Math.round((durationMs * opts.fps) / 1000)); + const blockHtml = readFileSync(blockHtmlPath, 'utf-8'); + const hash = computeBlockHash(blockHtml, cue.params, durationMs, opts.fps, width, height); + const pngDir = join(opts.cacheDir, hash); + const expectedLastFrame = join(pngDir, `frame_${String(frameCount - 1).padStart(4, '0')}.png`); + + if (!existsSync(expectedLastFrame)) { + if (!browser) { + browser = await chromium.launch({ + args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blacklist'], + }); + } + await renderBlockFrames({ + blockHtmlPath, + outputDir: pngDir, + durationMs, + fps: opts.fps, + width, + height, + params: cue.params, + holdLastFrame: cue.holdLastFrame, + browser, + }); + } + + results.push({ + name: cue.name, + pngDir, + frameCount, + fps: opts.fps, + startMs: cue.startMs, + endMs: cue.endMs, + width, + height, + fit: cue.fit, + }); + } + } finally { + if (browser) await browser.close(); + } + + return results; +} diff --git a/src/pipeline.ts b/src/pipeline.ts index 560fe9a..becbfe7 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -21,6 +21,7 @@ import { resolveExportSize, type ArgoConfig } from './config.js'; import { getVideoDurationMs } from './media.js'; import { buildOverlayPngsForImport } from './overlays/render-to-png.js'; import { renderShaderTransitions } from './transitions/shader-render.js'; +import { resolveHfBlockCues, renderHfBlocks } from './hf/block-render.js'; // Note: MusicGen (AI music generation) is a preview-only feature — runs in browser via WebGPU. // Pipeline uses saved WAV files via audio.music config path. import { @@ -285,7 +286,12 @@ export async function runPipeline( // Read per-scene playback speeds from scenes manifest const manifestPath = `${config.demosDir}/${demoName}.scenes.json`; const sceneSpeeds: SceneSpeedMap = {}; - let rawManifest: Array<{ scene?: string; playbackSpeed?: number; post?: Array<{ type?: string; atMs?: number; durationMs?: number }> }> = []; + let rawManifest: Array<{ + scene?: string; + playbackSpeed?: number; + post?: Array<{ type?: string; atMs?: number; durationMs?: number }>; + overlay?: { type?: string; [k: string]: unknown }; + }> = []; try { rawManifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); for (const entry of rawManifest) { @@ -449,6 +455,17 @@ export async function runPipeline( })); } + // hf-block cutaways — pre-render installed hyperframes blocks (cache-hit cheap) + const hfBlockCues = resolveHfBlockCues(rawManifest, finalPlacements); + if (hfBlockCues.length > 0) { + exportOptions.hfBlocks = await renderHfBlocks({ + cues: hfBlockCues, + blocksDir: config.blocksDir, + cacheDir: join(argoDir, 'hf-blocks'), + fps: config.video?.fps ?? 30, + }); + } + const outputPath = await exportVideo(exportOptions); // Scene report @@ -660,6 +677,19 @@ export async function runPipeline( })); } + // hf-block cutaways for this variant — same manifest (outer `rawManifest`, + // still overlay-typed here — the text-only shadow above is try-block scoped), + // own placements + cache dir. + const variantHfBlockCues = resolveHfBlockCues(rawManifest, variantPlacements); + const variantHfBlocks = variantHfBlockCues.length > 0 + ? await renderHfBlocks({ + cues: variantHfBlockCues, + blocksDir: config.blocksDir, + cacheDir: join('.argo', variantSubdir, 'hf-blocks'), + fps: config.video?.fps ?? 30, + }) + : undefined; + const variantOutputPath = await exportVideo({ demoName: variantSubdir, argoDir: '.argo', @@ -686,6 +716,7 @@ export async function runPipeline( freezeSpecs: variantResolvedFreezes.length > 0 ? variantResolvedFreezes : undefined, overlayPngs: variantOverlayPngs, shaderTransitions: variantShaderTransitions, + hfBlocks: variantHfBlocks, encoder: config.export.encoder, encoderDefault: 'cpu', }); diff --git a/src/preview.ts b/src/preview.ts index c8c3a30..3a2acaa 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -26,6 +26,8 @@ import { generateFramePng } from './frame.js'; import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs, type FreezeSpec } from './freeze.js'; import { buildOverlayPngsForImport, isImportedVideo, type RenderedOverlayPng } from './overlays/render-to-png.js'; import { renderShaderTransitions, type ShaderTransitionRenderResult } from './transitions/shader-render.js'; +import { resolveHfBlockCues, renderHfBlocks } from './hf/block-render.js'; +import type { RenderedHfBlock } from './hf/block-filter.js'; import { detectVideoTheme, getVideoDurationMs, probeEdgeColors } from './media.js'; import { computeWaveform } from './preview-waveform.js'; import type { BackgroundTheme } from './overlays/zones.js'; @@ -51,6 +53,7 @@ export interface PreviewExportConfig { frame?: import('./config.js').FrameConfig; motionBlur?: boolean | { intensity: number }; encoder?: 'cpu' | 'gpu'; + blocksDir?: string; } export interface PreviewOptions { @@ -1091,6 +1094,18 @@ export async function startPreviewServer(options: PreviewOptions): Promise<{ url })); } + // hf-block cutaways — pre-render installed hyperframes blocks (cache-hit cheap) + let previewHfBlocks: RenderedHfBlock[] | undefined; + const previewHfBlockCues = resolveHfBlockCues(scenes, freezeAdjustedPlacements); + if (previewHfBlockCues.length > 0) { + previewHfBlocks = await renderHfBlocks({ + cues: previewHfBlockCues, + blocksDir: ec?.blocksDir ?? 'blocks', + cacheDir: join(demoDir, 'hf-blocks'), + fps: ec?.fps ?? 30, + }); + } + // Export — use full config so output matches argo pipeline await exportVideo({ demoName, @@ -1122,6 +1137,7 @@ export async function startPreviewServer(options: PreviewOptions): Promise<{ url freezeSpecs: previewResolvedFreezes.length > 0 ? previewResolvedFreezes : undefined, overlayPngs, shaderTransitions: previewShaderTransitions, + hfBlocks: previewHfBlocks, encoder: ec?.encoder, encoderDefault: 'gpu', }); diff --git a/tests/hf/render-hf-blocks.test.ts b/tests/hf/render-hf-blocks.test.ts new file mode 100644 index 0000000..feacc53 --- /dev/null +++ b/tests/hf/render-hf-blocks.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveHfBlockCues, renderHfBlocks } from '../../src/hf/block-render.js'; + +const FIXTURE_BLOCK = ` +
+ +`; + +describe('resolveHfBlockCues', () => { + const placements = [ + { scene: 'intro', startMs: 0, endMs: 4000 }, + { scene: 'outro', startMs: 10_000, endMs: 13_000 }, + ]; + + it('maps cues onto placement windows with defaults', () => { + const manifest = [ + { scene: 'intro', overlay: { type: 'lower-third', title: 'x' } }, + { scene: 'outro', overlay: { type: 'hf-block', name: 'logo-outro' } }, + ]; + const cues = resolveHfBlockCues(manifest, placements); + expect(cues).toEqual([{ + name: 'logo-outro', params: undefined, fit: 'cover', holdLastFrame: false, + startMs: 10_000, endMs: 13_000, + }]); + }); + + it('caps cue durationMs at the placement window but lets it extend for the last scene', () => { + const manifest = [ + { scene: 'intro', overlay: { type: 'hf-block', name: 'a', durationMs: 99_000 } }, + { scene: 'outro', overlay: { type: 'hf-block', name: 'b', durationMs: 20_000 } }, + ]; + const cues = resolveHfBlockCues(manifest, placements); + expect(cues[0].endMs).toBe(10_000); // capped at next placement start + expect(cues[1].endMs).toBe(30_000); // last scene: extends; ffmpeg clips at video end + }); + + it('skips scenes without placements', () => { + const cues = resolveHfBlockCues( + [{ scene: 'ghost', overlay: { type: 'hf-block', name: 'a' } }], + placements, + ); + expect(cues).toEqual([]); + }); +}); + +describe('renderHfBlocks', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'argo-renderhf-')); + mkdirSync(join(tmp, 'blocks', 'fx'), { recursive: true }); + writeFileSync(join(tmp, 'blocks', 'fx', 'fx.html'), FIXTURE_BLOCK); + writeFileSync(join(tmp, 'blocks', 'fx', 'registry-item.json'), JSON.stringify({ + name: 'fx', type: 'hyperframes:block', files: [{ path: 'fx.html' }], + dimensions: { width: 320, height: 180 }, + })); + }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('renders, caches, and returns composite-ready records', async () => { + const cues = [{ name: 'fx', params: undefined, fit: 'cover' as const, holdLastFrame: false, startMs: 500, endMs: 1500 }]; + const r1 = await renderHfBlocks({ cues, blocksDir: join(tmp, 'blocks'), cacheDir: join(tmp, 'cache'), fps: 10 }); + expect(r1).toHaveLength(1); + expect(r1[0]).toMatchObject({ name: 'fx', startMs: 500, endMs: 1500, fps: 10, width: 320, height: 180, fit: 'cover', frameCount: 10 }); + expect(readdirSync(r1[0].pngDir).filter((f) => f.endsWith('.png'))).toHaveLength(10); + + // second run: cache hit — same pngDir, no re-render (mtime of first frame unchanged) + const before = readdirSync(r1[0].pngDir).length; + const r2 = await renderHfBlocks({ cues, blocksDir: join(tmp, 'blocks'), cacheDir: join(tmp, 'cache'), fps: 10 }); + expect(r2[0].pngDir).toBe(r1[0].pngDir); + expect(readdirSync(r2[0].pngDir).length).toBe(before); + }, 60_000); + + it('throws with an install hint for a missing block', async () => { + await expect(renderHfBlocks({ + cues: [{ name: 'nope', params: undefined, fit: 'cover', holdLastFrame: false, startMs: 0, endMs: 1000 }], + blocksDir: join(tmp, 'blocks'), cacheDir: join(tmp, 'cache'), fps: 10, + })).rejects.toThrow(/argo add nope/); + }); +}); From 217f11f0e94f6720787db750a8251f8ade036889 Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 17:02:24 -0700 Subject: [PATCH 29/34] docs: hf-block cutaway cue, pre-render cache, speedRamp caveat --- CLAUDE.md | 11 +++++++++++ README.md | 22 ++++++++++++++++++++++ skills/argo-guide/SKILL.md | 16 +++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index efcee37..eafeca5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,16 @@ Trust model: components are trusted-at-install (user ran `argo add`, files are g `argo validate` checks that referenced hf-components are installed (`blocks//.html` exists) and validates `export.transition.accent` as a 6-digit hex color. +**`hf-block` cutaway cue** (`src/hf/block-render.ts`, `src/hf/block-filter.ts`): `{ type: 'hf-block', name, params?, durationMs?, fit?: 'cover' | {x,y,scale}, holdLastFrame? }`. Unlike `hf-component` (live DOM injection), `hf-block` is a no-op at recording time — it only paces the wait, nothing is injected into the page. The block's paused GSAP timeline is rendered later, at export time, by a dedicated headless Chromium pre-pass (`renderBlockFrames`) that loads the installed `blocks//.html`, waits for the hyperframes convention `window.__timelines[] = { duration(), pause(), seek(t) }` to register, then seeks it frame-by-frame and screenshots each frame with `omitBackground: true` for an alpha PNG sequence. `holdLastFrame` pins the timeline at its native duration instead of linearly retiming the whole animation into the window; sampling is edge-inclusive (last frame lands exactly at the window end) with an N=1 midpoint fallback. + +Content-addressed cache at `.argo//hf-blocks//` — `computeBlockHash(blockHtml, params, durationMs, fps, width, height)` — a cache hit skips the Chromium launch entirely (`renderHfBlocks` in `block-render.ts` shares one browser instance lazily across cache misses in a batch, mirroring the shader-render pre-pass pattern). + +`resolveHfBlockCues(rawManifest, placements)` anchors each cue to its scene's placement window: `endMs = startMs + (durationMs ?? windowMs)`, capped at the placement's own `endMs` unless the requested duration overshoots — in which case it's allowed to run into the gap after the scene, capped at the next placement's `startMs` (uncapped on the last scene; ffmpeg's `enable=between(t,...)` window is naturally clipped by the video's total duration regardless). Scenes with no matching placement are skipped with a warning, same as overlays/subtitles. + +Compositing (`buildHfBlockFilters`) adds one `-framerate`/`image2` input per cue and an `enable`-gated `overlay` with `eof_action=pass` — applied immediately after camera moves and overlay PNGs, before the frame effect/watermark (same layer priority as overlay PNGs). It's a pure cutaway: `-shortest` and total output duration are unaffected. + +Wired at all four export paths: pipeline primary, pipeline variants, CLI `argo export`, and `argo preview` export (`PreviewExportConfig` gained `blocksDir`). Blocks need network access at export time — real registry blocks load GSAP + Google Fonts from CDNs during the pre-render pass, so offline exports of demos with `hf-block` cues will fail at that step. **Known limitation:** `hf-block` + `export.speedRamp` is untested and can misalign windows — `resolveHfBlockCues` anchors windows to post-ramp scene placements (the same ones subtitles/chapters use), but the ramp's dead-time compression (`computeSegments` in `src/speed-ramp.ts`) is computed independently in source-timeline coordinates before cues are resolved. Avoid combining the two until this is verified end-to-end. + ### Effects (`src/effects.ts`) `showConfetti(page, opts?)` — non-blocking by default (fire-and-forget safe). Injects a canvas-based confetti animation via `page.evaluate()`. Two spread modes: `burst` (Raycast-style, center-top fan) and `rain` (full-width fall). `emoji: '🎃'` or `emoji: ['🎄', '⭐']` renders emoji characters instead of colored rectangles. Set `wait: true` to block until animation completes. Errors from page/context disposal are swallowed; all other errors surface as warnings. @@ -331,6 +341,7 @@ Custom `test` fixture extends Playwright's `test` with a `narration` fixture tha - ffmpeg `gradients` source filter rejects negative `x0/y0/x1/y1` values — angle-to-coordinate conversion can produce negatives for certain angles. Always clamp. - `narration.mark()` does sync `appendFileSync` which can trigger app re-renders on the same event loop tick — overlay injection fence mitigates but apps with very aggressive DOM updates may still need manual `waitForTimeout()` after `mark()`. - `-shortest` must be skipped when frame PNG overlay is present — PNG has 0 duration and truncates the entire output. +- `hf-block` cutaway cues are not validated in combination with `export.speedRamp` — cue windows are resolved from post-ramp placements while the ramp's gap-compression segments are computed independently in source-timeline coordinates, which can misalign the block window against the retimed video. Don't combine them until this is revisited. ## Security Invariants diff --git a/README.md b/README.md index ad728d0..3751e9a 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,28 @@ await removeComponent(page, 'grain-overlay'); > **Note:** `caption-*` components install like any other item but don't yet support word-level timing (future work). Components can ship a ` + + + +
+
+ +
+ + +
+ + + + + + + + +
+ +
+
Demos as code. Motion as catalog.
+
+ argo × hyperframes +
+
+
+ + + + +
+ + diff --git a/blocks/logo-outro/registry-item.json b/blocks/logo-outro/registry-item.json new file mode 100644 index 0000000..a44c982 --- /dev/null +++ b/blocks/logo-outro/registry-item.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "logo-outro", + "type": "hyperframes:block", + "title": "Logo Outro", + "description": "Cinematic logo reveal with piece-by-piece assembly, glow bloom, tagline fade-in, and URL pill", + "tags": [ + "branding", + "outro", + "logo" + ], + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 6, + "files": [ + { + "path": "logo-outro.html", + "target": "compositions/logo-outro.html", + "type": "hyperframes:composition" + } + ], + "preview": { + "video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.mp4", + "poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" + }, + "params": [ + { + "key": "--bg-color", + "label": "Background", + "type": "color", + "default": "#0a0a0f" + }, + { + "key": "--accent-color", + "label": "Accent", + "type": "color", + "default": "#1a1a1f" + } + ] +} \ No newline at end of file diff --git a/blocks/vignette/registry-item.json b/blocks/vignette/registry-item.json new file mode 100644 index 0000000..a1c0283 --- /dev/null +++ b/blocks/vignette/registry-item.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "vignette", + "type": "hyperframes:component", + "title": "Vignette", + "description": "Cinematic radial vignette overlay using a pure-CSS gradient — darkens the edges to pull focus toward the center", + "tags": [ + "vignette", + "overlay", + "cinematic", + "effect" + ], + "files": [ + { + "path": "vignette.html", + "target": "compositions/components/vignette.html", + "type": "hyperframes:snippet" + } + ], + "preview": { + "poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/vignette.png" + } +} \ No newline at end of file diff --git a/blocks/vignette/vignette.html b/blocks/vignette/vignette.html new file mode 100644 index 0000000..18ba2d0 --- /dev/null +++ b/blocks/vignette/vignette.html @@ -0,0 +1,51 @@ + + +
+ + + + diff --git a/demos/hyperframes-showcase.config.mjs b/demos/hyperframes-showcase.config.mjs new file mode 100644 index 0000000..18fca87 --- /dev/null +++ b/demos/hyperframes-showcase.config.mjs @@ -0,0 +1,28 @@ +import { defineConfig } from '@argo-video/cli'; + +export default defineConfig({ + baseURL: 'http://localhost:8976', + demosDir: 'demos', + outputDir: 'videos', + blocksDir: 'blocks', + tts: { defaultVoice: 'af_heart', defaultSpeed: 1.0 }, + video: { + width: 1920, + height: 1080, + fps: 30, + browser: 'chromium', + captureMode: 'jpeg-stitch', + jpegQuality: 95, + showActions: false, + }, + export: { + preset: 'slow', + // Dark story-page backdrops — same banding rationale as the main showcase. + crf: 14, + encoder: 'cpu', + // Track 1 dogfood: hyperframes-ported shader at every boundary, tinted + // with the brand accent (domain-warp uses accentDark/accentBright for its + // edge glow). + transition: { type: 'shader', shader: 'domain-warp', durationMs: 2400, accent: '#0ea5e9' }, + }, +}); diff --git a/demos/hyperframes-showcase.demo.ts b/demos/hyperframes-showcase.demo.ts new file mode 100644 index 0000000..34d637a --- /dev/null +++ b/demos/hyperframes-showcase.demo.ts @@ -0,0 +1,83 @@ +/** + * Argo × HyperFrames — "Better Together" showcase. + * + * Dogfoods the whole hyperframes integration: + * - Track 1: domain-warp shader transition at every scene boundary (see config) + * - Track 2: vignette + grain-overlay components applied live during recording + * - Track 3: logo-outro block composited as the end card at export + * + * Prerequisites: + * 1. Install catalog items: npx argo add vignette && npx argo add grain-overlay && npx argo add logo-outro + * 2. Serve the story page: python3 -m http.server 8976 --directory demos + * 3. Run pipeline: npx tsx bin/argo.js pipeline hyperframes-showcase --config demos/hyperframes-showcase.config.mjs + * + * Scene 'catalog' visits the live hyperframes site — network required. + */ +import { test } from '@argo-video/cli'; +import { showOverlay, applyComponent, zoomTo } from '@argo-video/cli'; + +test('hyperframes-showcase', async ({ page, narration }) => { + test.setTimeout(300_000); + + await page.goto('/hyperframes-showcase.html'); + await page.waitForTimeout(700); + + await narration.startRecording(page); + + // ── Scene 1: hook (story page) ──────────────────────────────────────────── + narration.mark('hook'); + await page.waitForTimeout(narration.durationFor('hook')); + + // ── Scene 2: catalog (live hyperframes site) ───────────────────────────── + // Content change BEFORE mark() so the shader transition lands between pages. + await page.goto('https://hyperframes.heygen.com', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1200); // let the JS-rendered hero settle + narration.mark('catalog'); + const catalogMs = narration.sceneDuration('catalog'); + // Gentle post-export camera push on the hero first (zoompan crops the + // frame, so the lower-third waits until the camera settles). + const hero = page.locator('main'); + if (await hero.count()) { + await zoomTo(page, 'main', { + scale: 1.18, + duration: 3200, + holdMs: Math.floor(catalogMs * 0.25), + narration, + }); + } + await page.waitForTimeout(Math.floor(catalogMs * 0.45)); + // Camera is back out — safe to show the lower-third for the scene's tail. + void showOverlay(page, 'catalog', Math.floor(catalogMs * 0.45)); + await page.mouse.wheel(0, 700); + await page.waitForTimeout(Math.floor(catalogMs * 0.25)); + await page.mouse.wheel(0, 700); + await page.waitForTimeout(narration.durationFor('catalog')); + + // ── Scene 3: argo (story page) ──────────────────────────────────────────── + await page.goto('/hyperframes-showcase.html#argo', { waitUntil: 'domcontentloaded' }); + await page.locator('#argo').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + narration.mark('argo'); + await page.waitForTimeout(narration.durationFor('argo')); + + // ── Scene 4: better together (components land on this footage) ─────────── + await page.locator('#better').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + narration.mark('better'); + const betterMs = narration.sceneDuration('better'); + // Let the narration set up the moment, then apply the grade mid-sentence + // ("watch the grade land") so the change is visible on screen. + await page.waitForTimeout(Math.floor(betterMs * 0.35)); + await applyComponent(page, 'vignette', { params: { '--vignette-size': '52%' } }); + await applyComponent(page, 'grain-overlay'); + void showOverlay(page, 'better', Math.floor(betterMs * 0.5)); + await page.waitForTimeout(narration.durationFor('better')); + + // ── Scene 5: outro (quiet backdrop; logo-outro composites at export) ───── + await page.locator('#outro').scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + narration.mark('outro'); + // The hf-block cue in the manifest is an export-time cutaway — the script + // only holds the scene for its narration length. + await page.waitForTimeout(narration.durationFor('outro')); +}); diff --git a/demos/hyperframes-showcase.html b/demos/hyperframes-showcase.html new file mode 100644 index 0000000..3e4b119 --- /dev/null +++ b/demos/hyperframes-showcase.html @@ -0,0 +1,169 @@ + + + + + +Argo × HyperFrames + + + + +
+
Argo × HyperFrames
+

Product demos,
but cinematic.

+

This video is code. A Playwright script recorded it, an AI voice narrated it, and a motion catalog styled it — no editor, no timeline, no re-takes.

+
+ +
+
Meet Argo
+

Record the real thing.

+

Argo drives your actual product with Playwright, narrates it with local AI voiceover, directs the camera, and exports a polished MP4 — one command, end to end.

+ $ argo pipeline my-demo +
+ +
+
Better together
+

Your product. Their motion.

+
+
+ Argo brings +

The recording

+

Real app, real interactions, AI narration, camera direction, one-command export.

+
+
×
+
+ HyperFrames brings +

The motion catalog

+

Shader transitions, film grade, branded end cards — installed with one command.

+
+
+ $ argo add vignette grain-overlay logo-outro +
+ +
+
Argo × HyperFrames
+

Ship the demo.

+

github.com/shreyaskarnik/argo  ·  github.com/heygen-com/hyperframes

+
+ + + diff --git a/demos/hyperframes-showcase.scenes.json b/demos/hyperframes-showcase.scenes.json new file mode 100644 index 0000000..0516f7d --- /dev/null +++ b/demos/hyperframes-showcase.scenes.json @@ -0,0 +1,52 @@ +[ + { + "scene": "hook", + "text": "This video was not edited. It was compiled. A Playwright script recorded it, a local A I voice narrated it, and every transition you are about to see came from a motion catalog.", + "speed": 0.94 + }, + { + "scene": "catalog", + "text": "This is Hyper Frames, by Hey Jen. An open source motion library for video: over a hundred and forty blocks, components, and shader transitions, all written in plain H T M L.", + "speed": 0.95, + "overlay": { + "type": "lower-third", + "text": "hyperframes.heygen.com — 142 blocks · components · shader transitions", + "placement": "bottom-left", + "motion": { + "type": "gsap", + "in": { "from": { "x": -40, "opacity": 0 }, "duration": 0.5, "ease": "power3.out" }, + "out": { "to": { "opacity": 0 }, "duration": 0.35 } + } + } + }, + { + "scene": "argo", + "text": "And this is argo. It drives your real product with Playwright, narrates with local text to speech, directs the camera, and exports a finished video. One command, end to end.", + "speed": 0.94 + }, + { + "scene": "better", + "text": "Together they are more than the sum. argo add installs film grain, a cinematic vignette, and branded end cards straight from the Hyper Frames catalog. Watch the grade land on this very footage.", + "speed": 0.94, + "overlay": { + "type": "callout", + "text": "argo add vignette grain-overlay", + "placement": "top-right", + "motion": { + "type": "gsap", + "in": { "from": { "y": -24, "opacity": 0 }, "duration": 0.45, "ease": "back.out" }, + "out": { "to": { "opacity": 0 }, "duration": 0.3 } + } + } + }, + { + "scene": "outro", + "text": "Every scene boundary in this video was a Hyper Frames shader, rendered by argo's export pipeline. And this end card? A catalog block, pre rendered frame by frame. Demos as code. Motion as catalog.", + "speed": 0.93, + "overlay": { + "type": "hf-block", + "name": "logo-outro", + "holdLastFrame": true + } + } +] From 4862f4414d448205bf60f2a85fd8b4a7f3cb86fc Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 7 Jul 2026 19:52:21 -0700 Subject: [PATCH 32/34] =?UTF-8?q?feat(demos):=20comprehensive=20argo=20?= =?UTF-8?q?=C3=97=20hyperframes=20full-tour=20showcase=20(3:21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13 scenes covering every major feature of both systems: multi-voice TTS, all overlay templates + x-post block, camera suite, post-export zoom, data-chart hf-block cutaway, live vignette/grain/shimmer components, confetti + freeze-frame hold, 16-shader grid, rebranded logo-outro. Recording hardening learned the hard way: pages with autoplaying