Commit Graph
15 Commits
Author SHA1 Message Date
Miguel Ángel cf40ba987f Remove tunnel-based HyperFrames publish flow 2026-04-22 01:04:59 -04:00
Miguel Ángel 66df6fa922 feat(cli): hyperframes publish — share projects via a public URL
One-command share: `hyperframes publish` starts the preview server,
opens a public HTTPS tunnel to it, and prints a URL the user can paste
into Slack / Figma / iPhone Safari / anywhere. Token-gated, read-only
by default, explicit consent before exposure, auto-rebuilds the studio
if its assets are missing so users never see a broken tunnel.

# What changed

- packages/cli/src/commands/publish.ts — new command. Consent prompt,
  mint session token, start preview through a fetch wrapper that gates
  the studio server, open the tunnel, print the URL. Ctrl-C reaps both
  children cleanly.
- packages/cli/src/utils/tunnel.ts — provider detection
  (cloudflared preferred, tuns.sh ssh fallback) and openTunnel that
  spawns the child and resolves with the first emitted public URL.
- packages/cli/src/utils/publishSecurity.ts — shared-secret token gate,
  mutation gate (read-only by default), read-only UI injection,
  interactive consent prompt.
- packages/cli/src/server/studioServer.ts — StudioAssetsMissingError
  thrown at construction if dist is missing (no lazy-500 at request
  time). Fixed the dev-fallback path: was three levels up at
  `hyperframes-oss/studio/dist`, now correctly two at
  `packages/studio/dist`.
- packages/cli/scripts/build-copy.sh — replaces the old silent
  `cp -r ../studio/dist/*`. Runs under `set -euo pipefail`, asserts
  the source index.html exists before copying, asserts the
  destination index.html exists after. A zero-file copy can no longer
  ship a broken CLI bundle.
- Root package.json — build now serializes studio first, then the
  rest in parallel. CLI dropped its nested `build:studio`; the two
  parallel `vite build` invocations were clobbering each other's
  packages/studio/dist.
- packages/cli/src/cli.ts, src/help.ts — register publish in the
  CLI, add it to the Getting Started group and root examples.
- docs/packages/cli.mdx — documents the command, flags, and the
  read-only security model.

# Tunnel

Auto-picks the first available of:

1. cloudflared — Cloudflare's free quick tunnel. Most reliable.
   `brew install cloudflared` (Linux/Windows binaries equivalent). No
   account, no keys, no config.
2. tuns.sh — zero-install ssh -R fallback. Works on every machine
   with OpenSSH.

`--provider cloudflared | tuns | auto` forces one.

# Security hardening

The studio server was built for localhost and everything is
unauthenticated. PUT/POST/DELETE/PATCH on /api/projects/:id/files/*
reads and overwrites the project directory. Exposing that surface
unguarded over a public URL is how you ship a "drop a keylogger in
their index.html" bug. Four composed defences:

1. Token gate. On startup, mint a 32-byte base64url token via
   crypto.randomBytes. Public URL is
   `${tunnelUrl}/?t=${token}#project/${name}`. First hit validates in
   constant time (timingSafeEqual), sets an httpOnly; Secure;
   SameSite=Lax session cookie, and 302-redirects to a clean URL so
   the token never lands in browser history or Referer. Every
   subsequent request needs the cookie. Anything else returns 404
   (deliberately, not 401/403 — do not advertise that a server is
   here). Cookie TTL: 12 h. Implemented as a fetch wrapper rather
   than a Hono middleware because the studio routes are registered
   before publish gets the app; middlewares only apply to routes
   registered after them. Wrapping fetch gates traffic at the HTTP
   boundary and sidesteps ordering entirely.
2. Mutation gate. When --allow-edit is not set, refuse writes/deletes
   on `/api/projects/:id/files/*`,
   `POST /api/projects/:id/duplicate-file`, and
   `POST /api/projects/:id/render`. Returns
   `403 { "error": "forbidden", "reason": "…re-run with --allow-edit…" }`.
   Authenticated reads still work.
3. Read-only UI enforcement. The mutation gate closes the server
   side, but the studio UI does not know about the mode. Without
   this layer, visitors would type into a Monaco editor whose saves
   silently 403. Every response carries
   `X-HF-Publish-Readonly: 1`. HTML responses from the SPA (NOT
   `/api/*`, so the composition bundle served inside the player
   iframe is untouched) get a small inline <style> + <script>
   injected before `</head>`. The style neutralises Monaco /
   CodeMirror surfaces via pointer-events: none,
   caret-color: transparent, user-select: none. The script sets
   window.__HF_PUBLISH_READONLY__ = true, renders a compact pill
   centered at the top of the viewport ("READ-ONLY · Published
   tunnel"), and monkey-patches window.fetch to short-circuit
   mutating calls client-side so the UI does not loop through
   doomed 403s.
4. Explicit consent on startup. Before the server binds, a clack
   confirm prompt spells out the exposure: token-is-a-password
   guidance, the 12 h session lifetime, and — when --allow-edit is
   set — a warning that visitors can read/write/delete project
   files. --yes / -y skips for scripts. In non-TTY environments
   without --yes, publish refuses to proceed rather than silently
   exposing.

# Auto-repair for missing studio assets

Previously, running publish against a dev checkout without a built
studio produced "Studio not found. Rebuild with: pnpm run build" on
every request — after the tunnel was open and the URL shared. We
fix it instead: createStudioServer throws
StudioAssetsMissingError at construction, publish catches it,
spawns `bun run build` in packages/studio with a spinner, retries
createStudioServer once the build succeeds. The error surface only
fires when auto-rebuild itself fails (broken global install, etc.),
and lists every path searched plus the `bun install && bun run
build` guidance. End-to-end: deleting both packages/studio/dist
and packages/cli/dist/studio and running publish now rebuilds the
studio and opens the tunnel without user intervention.

# Flags

- --port <n>               Preview port (default 3002).
- --provider <p>           cloudflared | tuns | auto.
- --allow-edit             Remote visitors can write/delete files.
                           Off by default.
- --yes / -y               Skip the consent prompt.

# Properties worth calling out

- No info leak on unauthenticated probes — random visitors see 404
  whether a path exists or not.
- Token never leaves the first URL — redirect strips it before any
  HTML is served, so no iframe Referer can leak it.
- Constant-time comparison for both token and cookie checks.
- No persisted state — token lives for the lifetime of the command,
  Ctrl-C nukes it.
- Read-only cannot be bypassed by a malicious client; the
  server-side 403 is the real stop.
- zero-file build:copy can no longer ship a broken CLI.

# Tests

- src/utils/publishSecurity.test.ts — 23 cases: token exchange,
  cookie flow, mutation-gate allow/deny matrix,
  X-HF-Publish-Readonly header, HTML-injection + no-op on
  --allow-edit / non-HTML / /api/* paths.
- src/utils/tunnel.test.ts — 7 cases for the URL extractor
  (per-provider banner parsing, cross-provider contamination
  guard) and provider picker semantics.
- src/server/studioServer.test.ts — StudioAssetsMissingError shape
  and export for downstream catch blocks.
- Full CLI suite: 144/144 pass.
- tsc --noEmit clean, tsup build clean.
2026-04-18 02:52:12 +02:00
Miguel Ángel 9ef864d1f2 fix(docs): serve hyperframes.json / registry JSON schemas (#304) (#305)
Closes #304.

## Summary

The three `/schema/*.json` URLs baked into every Hyperframes project as `\$schema` references are 404ing on the live docs site — blocking editor autocomplete and validation.

- \`https://hyperframes.heygen.com/schema/hyperframes.json\` — **404** (missing entirely)
- \`https://hyperframes.heygen.com/schema/registry.json\` — **404** (only in npm package)
- \`https://hyperframes.heygen.com/schema/registry-item.json\` — **404** (only in npm package)

Mintlify serves top-level non-MDX dirs in \`docs/\` at \`/\<dir>/*\` (confirmed by \`docs/logo/*.svg\` → \`/logo/*.svg\`). This PR drops the three schemas into \`docs/schema/\` so the URLs resolve.

## What changed

| File | Role |
|---|---|
| \`docs/schema/hyperframes.json\` | **New.** Authored from the \`ProjectConfig\` type in \`packages/cli/src/utils/projectConfig.ts\`. |
| \`docs/schema/registry.json\` | Mirror of \`packages/core/schemas/registry.json\`. |
| \`docs/schema/registry-item.json\` | Mirror of \`packages/core/schemas/registry-item.json\`. |
| \`scripts/sync-schemas.ts\` | Keeps the registry mirrors in lockstep with their authoritative copies in \`packages/core/schemas/\`. \`--check\` mode fails the Docs workflow on drift. |
| \`.github/workflows/docs.yml\` | Runs \`tsx scripts/sync-schemas.ts --check\` on every PR touching docs or core schemas. |
| \`package.json\` | \`sync-schemas\` / \`sync-schemas:check\` npm scripts. |

## Why not make \`packages/core/schemas/\` authoritative for \`hyperframes.json\` too?

\`hyperframes.json\` is CLI config, not a core type. Keeping the schema in \`docs/\` avoids an artificial dependency between \`@hyperframes/core\` and \`@hyperframes/cli\`. If the two ever need to align, we can flip the direction then.

## Verification

- \`bun run sync-schemas:check\` → \`2/2 in sync\`.
- Ajv (draft 2020-12, in-process) validation against 9 cases:
  - ✓ real factory-series-c-video config
  - ✓ default shape from \`hyperframes init\`
  - ✓ \`\$schema\` is optional
  - ✓ missing registry → rejected
  - ✓ missing paths.assets → rejected
  - ✓ extra top-level key → rejected
  - ✓ empty registry string → rejected
  - ✓ empty block path → rejected
  - ✓ missing paths entirely → rejected

## Test plan

- [x] \`tsx scripts/sync-schemas.ts --check\` passes locally
- [x] Schemas parse as valid JSON and validate real/default project configs
- [x] After merge: \`curl -sI https://hyperframes.heygen.com/schema/hyperframes.json\` returns 200 once Mintlify redeploys
- [x] Same check for \`/schema/registry.json\` and \`/schema/registry-item.json\`
- [x] VS Code autocomplete and error-highlighting work on \`hyperframes.json\` without extra config

## Notes

- The Docs workflow now triggers on \`packages/core/schemas/**\` and \`scripts/sync-schemas.ts\` in addition to \`docs/**\`, so a core-schemas change that forgets to run \`sync-schemas\` will fail CI instead of silently publishing stale docs.
- No runtime / API changes to any package; ship independent of a version bump.
2026-04-17 17:43:43 +02:00
James RussoandClaude Opus 4.7 4ae5c0340f chore(docs): migrate docs/images/ media to static.heygen.ai CDN (#301)
Move all preview mp4/png/gif assets under docs/images/ out of the repo
and serve them from https://static.heygen.ai/hyperframes-oss/docs/images/
(backed by s3://heygen-public/hyperframes-oss/docs/images/, CloudFront).

Drops ~49MB from the working tree and, more importantly, ~49MB from every
future Mintlify build checkout. Combined with the (already-LFS-tracked)
producer snapshots, the remaining bloat in 'npx skills add heygen-com/
hyperframes' (see #300) is LFS smudge during clone — separate fix needed
in the skills CLI to pass GIT_LFS_SKIP_SMUDGE=1.

Changes:
- Delete docs/images/** (103 files, ~49MB). Files are uploaded to S3 already.
- Rewrite /images/* references in 44 MDX files, TemplateCard.jsx, and
  catalog-index.json to absolute CDN URLs.
- Update README.md img src to CDN URL (renders correctly on GitHub).
- Add docs/images/ to .gitignore so regenerated previews aren't committed.
- Add scripts/upload-docs-images.sh to sync docs/images/ → S3 after running
  the preview generators.
- Wire up bun run upload:docs-images and bun run generate:catalog-previews
  scripts in package.json.
- Update generator script docstrings to point at the upload step.

External contributors can still regenerate previews locally (mintlify dev
reads the CDN URLs, so broken previews appear only for newly added items
pending a maintainer upload). Maintainers run:
  bun run generate:catalog-previews --only <name>
  bun run upload:docs-images

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:13:43 -07:00
James RussoandClaude Opus 4.6 b23b0751da fix(player): parent-frame media playback for mobile (#266)
* fix(player): parent-frame media playback for mobile

Mobile browsers block media.play() inside iframes when the user
gesture happened in the parent frame — postMessage doesn't transfer
user activation (per the User Activation v2 spec).

## Problem

The player renders compositions in a sandboxed iframe. When a user
taps play in the parent frame, the player sends a postMessage to the
iframe's runtime, which calls audio.play(). On mobile, this fails
silently because the iframe has no user activation context.

## Solution

The player now extracts ALL timed media elements (audio/video with
data-start) from the iframe's DOM (same-origin access), creates
parent-frame copies, and disables the iframe originals. On play(),
parentMedia.play() runs synchronously in the gesture call stack,
satisfying mobile autoplay policy.

### Generic media handling

- Finds all `audio[data-start], video[data-start]` in the iframe
- Creates a parent-frame copy for each (Audio or Video element)
- Preserves data-start offsets for correct seek positioning
- Strips data-start from iframe elements so the runtime ignores them
- Falls back to iframe media for cross-origin iframes

### `audio-src` attribute

Convenience for the common single-narration case. When set, the
player starts preloading audio immediately — before the iframe loads.
This eliminates the loading delay that caused jittery playback.

### No active sync

Both parent media and the GSAP timeline are real-time systems. When
started simultaneously, they naturally stay within ~10ms — no drift
correction needed. Active sync with coarse granularity (50ms polling)
caused MORE jitter than it prevented via repeated audio seeks.

## CI

- Added unified `test` job replacing separate per-package test jobs
- Added root `test` script: `bun run --filter '*' test`
- New packages with test scripts are automatically included
- Added happy-dom for player DOM tests

## Tests

- 10 new tests for parent-frame media: preloading, play, pause,
  seek, muted/rate sync, cleanup, attribute changes
- All 21 player tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(shader-transitions): pass CI when no test files exist

Add --passWithNoTests to vitest run so the unified test job
doesn't fail on packages that have a test script but no test
files yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): update tests for new id field and GSAP lint rule

- normalize.test.ts: loadTranscript now assigns id fields (w0, w1, etc.)
  to SRT/VTT results and empty string for words-json passthrough
- lintProject.test.ts: add GSAP CDN script to validHtml() fixture to
  satisfy the missing_gsap_script lint rule added in core

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): add missing data-start/data-duration to validHtml fixture

The validHtml() test fixture was missing data-start and data-duration
attributes, triggering the root_composition_missing_data_start and
root_composition_missing_data_duration lint warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): fetch LFS objects for producer test job

Producer regression tests compare rendered output against reference MP4
files stored in git LFS. Without lfs: true, checkout fetches pointer
files instead of actual videos, causing "moov atom not found" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: remove redundant test-producer job

The regression workflow already runs the same 28 producer fixtures
in a Docker container with prod-matching Chrome/fonts/ffmpeg, sharded
across 8 parallel matrix jobs with 40-min timeouts. The CI test-producer
job was a duplicate that ran on bare runners with worse determinism
and a 15-min timeout too short for all fixtures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:20:07 -07:00
James Russo 05aceedd30 feat(docs): add template preview generation script (#159)
## What

Add a script that uses `@hyperframes/producer` to render PNG thumbnails of each built-in template. Output goes to `docs/images/templates/`.

## Why

User feedback: "I would have loved more visual examples of what's actually possible. I had to scaffold every template just to see what they look like." This is the foundation for a visual template gallery in the docs.

## How

- New `scripts/generate-template-previews.ts` uses the producer's `createFileServer` + `createCaptureSession` + `captureFrame` APIs — hyperframes renders its own templates
- Patches out `__VIDEO_SRC__` placeholders (same logic as `init.ts`) so templates render without a video file
- Captures a frame at t=2s for each template (skips `blank` — it's just empty scaffolding)
- Handles varying dimensions (vignelli is 1080x1920 portrait)
- Adds `pnpm generate:previews` npm script

## Test plan

- [x] `pnpm generate:previews` generates 8 PNGs in `docs/images/templates/`
- [x] Each PNG is visually correct (verified by viewing)
- [x] `--only <template>` flag works for single template generation
2026-03-31 14:23:11 -04:00
James RussoandClaude Opus 4.6 a9d49cd528 fix(cli): auto-copy all templates to dist and add skill lint (#153)
- Replace hardcoded template list in build:copy with `cp -r src/templates/*`
  so new templates are included automatically (kinetic-type, decision-tree,
  product-promo, nyt-graph were missing from published package)
- Fix captions SKILL.md: reword `!` and `>` in inline backticks that
  triggered Claude Code's bash permission checker
- Add scripts/lint-skills.ts to catch shell-unsafe patterns in SKILL.md
  files (runs as part of `bun run lint` in CI)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:33:54 -07:00
James 8d7c33a4aa fix(publish): add repository.url to all packages for npm provenance
npm's sigstore provenance verification requires package.json
repository.url to match the GitHub repo. Without it, publish
fails with E422 "expected to match https://github.com/heygen-com/hyperframes".
2026-03-27 22:07:55 +00:00
Miguel Ángel e1c1c6fb30 fix: rewrite workspace deps before npm publish (#67)
## Summary
- rewrite workspace protocol dependencies to publish-safe semver ranges before the npm publish workflow runs
- keep workspace protocol references in source manifests for normal monorepo development
- ensure the published `@hyperframes/producer` manifest no longer ships unresolved `workspace:` deps

## Why
The internal repo hit a Docker build failure because the published `@hyperframes/producer` metadata still contained `workspace:^` dependencies for `@hyperframes/core` and `@hyperframes/engine`. `npm install` cannot resolve those outside the monorepo, so the published package itself was the root cause.

## Validation
- `bun install --frozen-lockfile`
- `bun run build:producer`
- `bun run prepare:publish-manifests`
- `npm pack --workspace packages/core`
- `npm pack --workspace packages/engine`
- `npm pack --workspace packages/producer`
- installed the three tarballs together in a clean temp project with `npm install --ignore-scripts`
- extracted the producer tarball and verified its `package.json` contains `^0.1.3` for `@hyperframes/core` and `@hyperframes/engine`, not `workspace:^`
2026-03-26 21:14:39 +01:00
Vance Ingalls 94e25443ae build: migrate from pnpm to bun as package manager (#28)
## Summary
- Replace pnpm with bun for dependency installation, script running, and ad-hoc execution
- Keep pnpm for publish workflow only (`publishConfig` overrides + `--provenance`)
- `bun install` replaces `pnpm install` (~4-5x faster cold installs)
- `bun run` replaces `pnpm run` (~28x less startup overhead)
- `bunx` replaces `npx` in lefthook hooks
- CI workflows updated (`oven-sh/setup-bun@v2` + `actions/setup-node@v4`)
- `pnpm-lock.yaml` removed, `bun.lock` generated
- `pnpm-workspace.yaml` kept for publish compatibility
- CLI source code (`packages/cli/src/`) unchanged — shipped to end users who may not have bun

Part 5/5 of [VA-851](https://linear.app/heygen/issue/VA-851/pre-migration-configure-eslint-prettier-and-conventional-commits)

## Test plan
- [x] `bun run lint` — 0 errors
- [x] `bun run format:check` — all files pass
- [x] `bun run build` — all 5 packages build
- [x] 330 core tests pass
- [x] 18 engine tests pass
- [x] `publish.yml` unchanged (pnpm stays for npm publishing)
- [x] No `bunx`/`bun run` references in shipped source code (`packages/*/src/`)
2026-03-23 19:50:57 -07:00
Vance Ingalls 20be2ea1c2 style: apply oxfmt baseline formatting across all source files (#25)
## Summary
- Run `oxfmt .` across the entire codebase to establish formatted baseline
- 299 files changed — mechanical formatting only, no logic changes
- Double quotes, semicolons, 2-space indent, trailing commas, 100 print width

Part 3/4 of [VA-851](https://linear.app/heygen/issue/VA-851/pre-migration-configure-eslint-prettier-and-conventional-commits)

## Test plan
- [x] `pnpm format:check` — all 426 files pass
- [x] `pnpm -r typecheck` — all packages pass
- [x] `pnpm build` — all packages build
- [x] All 348 tests pass
2026-03-23 17:15:14 -07:00
Vance Ingalls 17e90f0671 build: add oxlint, oxfmt, commitlint, lefthook, knip, and editorconfig configs (#23)
## Summary
- Install oxlint, oxfmt, commitlint, lefthook, knip as dev dependencies
- Add `.oxlintrc.json` (correctness rules + React plugin)
- Add `.oxfmtrc.json` (double quotes, semicolons, 2-space indent, trailing commas)
- Add `commitlint.config.js` (conventional commits)
- Add `lefthook.yml` (pre-commit lint+format, commit-msg commitlint)
- Add `.editorconfig` and `knip.config.ts`
- Add scripts: `pnpm lint`, `pnpm format`, `pnpm format:check`, `pnpm knip`

Part 1/4 of [VA-851](https://linear.app/heygen/issue/VA-851/pre-migration-configure-eslint-prettier-and-conventional-commits)

## Test plan
- [x] `pnpm lint` runs (reports pre-existing errors, expected)
- [x] `pnpm format:check` runs (reports pre-existing diffs, expected)
- [x] `commitlint` validates and rejects messages correctly
- [x] lefthook hooks install via `pnpm run prepare`
- [x] `pnpm knip` runs
2026-03-23 16:05:47 -07:00
Vance IngallsandClaude Opus 4.6 0276255b76 fix: rename root package to avoid shadowing published CLI (#17)
`npx hyperframes init` failed with "could not determine executable to run"
because the monorepo root package.json shared the same name as the published
npm package. npx resolved the local root (which has no bin field) instead of
the published package. Renaming to "hyperframes-monorepo" follows the same
convention as remotion-monorepo.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:47:18 -07:00
James 306909534b ci: add tag-triggered npm publish workflow with OIDC provenance 2026-03-23 03:20:21 +00:00
Vance IngallsandClaude Opus 4.6 9f8e5ba5a1 initial code (#2)
* feat: initial code port from hyperframes-internal

Port all OSS-ready packages from the internal monorepo:
- @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime
- @hyperframes/cli — CLI for creating, previewing, and rendering compositions
- @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg)
- @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg)
- @hyperframes/ui-player — browser-based video player component
- @hyperframes/studio — composition editor (React frontend + Hono backend)

Includes regression test suite with Docker-based test harness.

All HeyGen-internal references, deployment infrastructure, and
proprietary assets have been removed. Package names migrated
from @app/* to @hyperframes/*.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: scrub internal codenames and stale references from OSS port

- Replace static.heygen.ai runtime URLs in test fixtures
- Remove internal CDN publish script (publish-hyperframe-runtime.ts)
- Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime
  with neutral names (studio, hyperframe-runtime, __hyperframeRuntime)
- Fix stale Vault API / localhost references in docs
- Remove broken deprecated_studio link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove remaining internal codenames and stale references

- Delete stale producer README.md and PIPELINE.md (referenced nonexistent files)
- Replace "Cerberus" codename with "HyperFrames" in test design reviews
- Replace magic-edit postMessage identifiers with hf-preview/hf-parent
- Rename debug-magic-edit-timeline.ts to debug-timeline.ts
- Replace "Motion Cut" with "HyperFrames" in Timeline comments
- Fix studio/CLI references to nonexistent archive package
  (use local data/projects/ dir, stub render proxy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:43:56 -07:00