diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index e9b1fc29b..d0dd1fbd5 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -35,6 +35,11 @@ "registry/**", "examples/**", ".github/workflows/fixtures/**", + // Picker assets — vendored slide templates (template.html, summary.html, + // deck-stage.js shims, styles.css) and the picker shell (design-picker.html). + // Loaded at runtime by iframes, not imported. Built/served by skills scripts. + "skills/hyperframes/templates/presentations/**", + "skills/hyperframes/templates/design-picker.html", ], "ignoreExports": [ // CLI command files: every command exports a const `examples` per the diff --git a/.gitignore b/.gitignore index 983925dfd..9f11828be 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,6 @@ coverage/ # Producer regression test failures (generated debugging artifacts) packages/producer/tests/*/failures/ -packages/producer/tests/distributed/*/failures/ packages/producer/tests/parity/fixtures/hyperframe.runtime.iife.js # Player perf test results (generated each run, attached as CI artifact) @@ -42,7 +41,6 @@ packages/player/tests/perf/results/ output/ renders/ !packages/producer/tests/*/output/ -!packages/producer/tests/distributed/*/output/ # Composition source media (large binaries) compositions/**/*.mp4 @@ -67,12 +65,7 @@ packages/producer/src/services/fontData.generated.ts # Local proof / test artifacts qa-artifacts/ my-video/ -examples/* -# Tracked OSS examples — negations override the blanket `examples/*` ignore. -!examples/aws-lambda -!examples/aws-lambda/** -!examples/k8s-jobs -!examples/k8s-jobs/** +examples/ packages/studio/data/ .desloppify/ .worktrees/ @@ -110,4 +103,10 @@ claude-design-hyperframes-video/ .claude/worktrees/ .claude/ docs/superpowers/ -.worktrees + +# hyperframes pick — generated picker + per-project picker data +.hyperframes/ + +# hyperframes pick — generated user-design template (created when DESIGN.html exists) +/templates/ + diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 18193617d..7ac76afb2 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -436,6 +436,29 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ + ### `pick` + + + Experimental. Hidden behind a feature flag. Set `HYPERFRAMES_DESIGN_PICKER=1` (or `true` / `on` / `yes`) to enable. Always enabled when running from the monorepo for contributors. + + + Open the design picker in your browser to choose a template and create a `design.md`: + + ```bash + HYPERFRAMES_DESIGN_PICKER=1 npx hyperframes pick + HYPERFRAMES_DESIGN_PICKER=1 npx hyperframes pick --port 8723 + HYPERFRAMES_DESIGN_PICKER=1 npx hyperframes pick --build-only + ``` + + | Flag | Description | + |------|-------------| + | `--port` | Port to serve the picker on (default: 8723) | + | `--build-only` | Build the picker HTML without serving it | + + Builds `.hyperframes/pick-design.html` from the installed skill template, starts a local HTTP server, and opens the picker. Requires the HyperFrames skills (`npx hyperframes skills`) and `python3` installed. + + The picker walks you through palette, typography, corners, density, depth, motion, and an optional shader background. Export the resulting `design.md` and paste it into your project root. + ### `preview` Start a live preview server with hot reload: @@ -860,99 +883,6 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ -## hyperframes lambda - -Deploy HyperFrames distributed rendering to AWS Lambda and drive renders from your laptop or CI. - -The `hyperframes lambda` command group wraps the `@hyperframes/aws-lambda` SDK plus AWS SAM so an end-to-end render is three commands: - -```bash -hyperframes lambda deploy -hyperframes lambda render ./my-project --width 1920 --height 1080 --wait -hyperframes lambda destroy # when you're done -``` - -### Prerequisites - -- AWS credentials configured (env vars, `~/.aws/credentials`, SSO, or IMDS). -- [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) on `PATH`. -- `bun` on `PATH` (used to build the Lambda handler ZIP). - -### Subcommands - -#### `lambda deploy` - -Builds `packages/aws-lambda/dist/handler.zip` and SAM-deploys the stack at `examples/aws-lambda/template.yaml`. On success, writes `/.hyperframes/lambda-stack-.json` so the other subcommands don't need to re-derive the bucket / state-machine ARN. - -```bash -hyperframes lambda deploy \ - --stack-name=hyperframes-prod \ - --region=us-east-1 \ - --concurrency=8 \ - --memory=10240 -``` - -Idempotent — re-running on the same `--stack-name` resolves to a no-op when nothing changed. - -#### `lambda sites create ` - -Tars + uploads `` to S3 with a content-addressed key. Returns a `siteId` you can reuse across multiple renders so a re-render of the same tree skips the upload. - -```bash -hyperframes lambda sites create ./my-project -# → siteId: abc1234deadbeef0 (stable across re-runs of the same tree) - -hyperframes lambda render ./my-project --site-id=abc1234deadbeef0 --width 1920 --height 1080 -``` - -#### `lambda render ` - -Starts a Step Functions execution. Returns immediately with a `renderId` (use `lambda progress` to poll) unless `--wait` is set, in which case the CLI blocks until the render finishes and streams per-chunk progress lines. - -```bash -hyperframes lambda render ./my-project \ - --width=1920 --height=1080 --fps=30 --format=mp4 \ - --chunk-size=240 --max-parallel-chunks=16 \ - --wait -``` - -`--json` swaps the human-readable output for a machine-parseable JSON snapshot. - -#### `lambda progress ` - -Prints one progress snapshot — overall percent, frames rendered, Lambda invocations, accrued cost, and any errors. Accepts either a bare `renderId` (resolved against the stack's state-machine ARN) or a full SFN execution ARN. - -```bash -hyperframes lambda progress hf-render-abcd1234 -``` - -#### `lambda destroy` - -Calls `sam delete --no-prompts` and drops the local state file. The render S3 bucket is configured with CloudFormation `Retain` so it survives destruction — empty and delete it via the AWS console / CLI if you want the storage back. - -#### `lambda policies role | user | validate` - -Print or validate the minimum IAM policy the CLI needs to deploy / invoke / destroy the stack. - -```bash -# Print an inline-policy doc you can attach to an IAM user that runs the CLI. -hyperframes lambda policies user - -# Print { TrustRelationship, InlinePolicy } for an IAM role (default: cloudformation principal). -hyperframes lambda policies role --principal=cloudformation - -# Validate a checked-in policy still covers the CLI's needs. -hyperframes lambda policies validate ./infra/iam/hyperframes-deploy.json -``` - -`validate` reads the JSON doc and checks the union of its `Effect: Allow` actions against the CLI's required action set, expanding `s3:*` / `s3:Get*` / `*` wildcards. Missing actions print to stderr and the command exits non-zero — wire it into CI to catch drift before the next deploy fails. - -The actions list is deliberately broad (`Resource: "*"`) because CloudFormation creates new function / state-machine / bucket ARNs on every adopter's first deploy. Adopters with stricter security postures should narrow `Resource` to the deployed ARNs after the first successful run. - -### State files - -`hyperframes lambda` keeps per-stack metadata under `/.hyperframes/lambda-stack-.json` so the verbs don't need to call `describe-stacks` every time. Commit the file to a repo or `.gitignore` it depending on your workflow — it contains the bucket name, state-machine ARN, and region, none of which are secrets but all of which are AWS-account-identifying. - ## hyperframes.json `hyperframes init` writes a `hyperframes.json` file at the root of every new project. `hyperframes add` reads it to know which registry to pull items from and where to drop them. Edit the file (or delete it to fall back to defaults) to reshape your project layout or point at a custom registry. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 06a748a46..8a0a37bb8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,35 +1,5 @@ #!/usr/bin/env node -// ── Worker entry path bootstrap (must run before any producer/engine load) ── -// The hf#677 worker_threads pools (`pngDecodeBlitWorkerPool`, -// `shaderTransitionWorkerPool`) live in the producer package and try to -// resolve their worker entry by probing for sibling `.js` files next to -// `import.meta.url`. When this CLI is bundled by tsup, the producer code is -// inlined into `cli.js`, but `import.meta.url` resolves to the producer's -// own dist path (NOT cli.js) on some module-graph layouts — so the sibling -// probe lands in a directory that does not contain the bundled workers. -// We emit the worker entries next to cli.js (see tsup.config.ts) and tell -// the pools where to find them via the published env-var overrides. The -// pools have an explicit `workerEntryPath` factory option as the canonical -// API, but setting the env vars here covers every call site without having -// to thread the path through the renderOrchestrator → captureHdrStage → -// captureHdrHybridLoop chain. -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { existsSync } from "node:fs"; - -(() => { - const here = dirname(fileURLToPath(import.meta.url)); - const shader = join(here, "shaderTransitionWorker.js"); - const png = join(here, "pngDecodeBlitWorker.js"); - if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) { - process.env.HF_SHADER_WORKER_ENTRY = shader; - } - if (!process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY && existsSync(png)) { - process.env.HF_PNG_DECODE_BLIT_WORKER_ENTRY = png; - } -})(); - // ── Fast-path exits ───────────────────────────────────────────────────────── // Check --version before importing anything heavy. This makes // `hyperframes --version` near-instant (~10ms vs ~80ms). @@ -65,6 +35,7 @@ const subCommands = { add: () => import("./commands/add.js").then((m) => m.default), catalog: () => import("./commands/catalog.js").then((m) => m.default), play: () => import("./commands/play.js").then((m) => m.default), + pick: () => import("./commands/pick.js").then((m) => m.default), preview: () => import("./commands/preview.js").then((m) => m.default), publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), @@ -86,7 +57,6 @@ const subCommands = { validate: () => import("./commands/validate.js").then((m) => m.default), snapshot: () => import("./commands/snapshot.js").then((m) => m.default), capture: () => import("./commands/capture.js").then((m) => m.default), - lambda: () => import("./commands/lambda.js").then((m) => m.default), }; const main = defineCommand({ diff --git a/packages/cli/src/commands/pick.ts b/packages/cli/src/commands/pick.ts new file mode 100644 index 000000000..79ee09d43 --- /dev/null +++ b/packages/cli/src/commands/pick.ts @@ -0,0 +1,376 @@ +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; +import * as clack from "@clack/prompts"; +import { c } from "../ui/colors.js"; +import { isDesignPickerEnabled } from "../utils/env.js"; + +export const examples: Example[] = [ + ["Open the design picker in your browser", "hyperframes pick"], + ["Use a custom port", "hyperframes pick --port 8723"], + ["Build the picker without serving", "hyperframes pick --build-only"], +]; + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".woff2": "font/woff2", +}; + +const DEFAULT_PICKER_DATA = { + prompt_desc: "Browse all templates", + prompt: { + title: "Template Picker", + headline: "Browse and configure", + subline: "Pick a template, fine-tune the design, export design.md", + section_desc: "All templates", + }, + prompt_text: { + headline: "Browse and configure", + sub: "Pick a template, fine-tune the design, export design.md", + taglines: { + bold: "BROWSE", + editorial: "Pick a direction", + playful: "Go!", + dark: "Configure", + technical: "Design System", + warm: "Choose", + }, + headlines: ["Browse", "Configure", "Pick", "Design"], + body: ["Pick a template and configure it."], + stats: ["34", "6", "5", "4"], + statLabels: ["Templates", "Palettes", "Fonts", "Corners"], + labels: ["BROWSE", "PICK", "CONFIGURE", "EXPORT"], + smalls: ["Browse", "Configure", "Export", "Pick"], + }, + architectures: [ + { + name: "All Templates", + description: "Browse the full library and configure your favorite.", + tag: "browse", + mood: "Open", + preview_html: + "
{{prompt_headline}}
{{prompt_sub}}
", + }, + ], + palettes: [ + { + name: "Light", + primary: "#F5F2EC", + secondary: "#111111", + tertiary: "#666666", + accent: "#E85D26", + desc: "Warm light canvas, dark ink, terra accent", + }, + { + name: "Editorial", + primary: "#FAF7EE", + secondary: "#1A1A1A", + tertiary: "#7E776A", + accent: "#2C5BFF", + desc: "Cream paper, ink text, cobalt accent", + }, + { + name: "Dark", + primary: "#0E1116", + secondary: "#ECECE8", + tertiary: "#5C6A82", + accent: "#E6FF3D", + desc: "Near-black canvas, off-white ink, neon accent", + }, + { + name: "Calm", + primary: "#EFEDE3", + secondary: "#2A2624", + tertiary: "#6E6357", + accent: "#7E8456", + desc: "Stone paper, warm ink, moss accent", + }, + ], + typepairs: [ + { + name: "Inter", + headline: { family: "Inter", weight: 800 }, + body: { family: "Inter", weight: 400 }, + preview: "Single Sans", + body_preview: "Inter for everything.", + desc: "Clean neutral", + }, + { + name: "Fraunces + Inter", + headline: { family: "Fraunces", weight: 600 }, + body: { family: "Inter", weight: 400 }, + preview: "Editorial Style", + body_preview: "Warm serif headline, neutral sans body.", + desc: "Editorial warmth", + }, + { + name: "Space Grotesk + IBM Plex Mono", + headline: { family: "Space Grotesk", weight: 700 }, + body: { family: "IBM Plex Mono", weight: 400 }, + preview: "Spec Sheet", + body_preview: "Geometric sans + mono body.", + desc: "Technical", + }, + { + name: "DM Serif Display + DM Sans", + headline: { family: "DM Serif Display", weight: 400 }, + body: { family: "DM Sans", weight: 400 }, + preview: "Premium", + body_preview: "High-contrast didone, clean sans body.", + desc: "Premium", + }, + ], + moodboards: [], +}; + +// fallow-ignore-next-line complexity +function findSkillsRoot(cwd: string): { templatesDir: string; scriptPath: string } | null { + // Prefer project-local install + const local = join(cwd, "skills", "hyperframes"); + if ( + existsSync(join(local, "templates", "design-picker.html")) && + existsSync(join(local, "scripts", "build-design-picker.py")) + ) { + return { + templatesDir: join(local, "templates"), + scriptPath: join(local, "scripts", "build-design-picker.py"), + }; + } + + // Dev mode: walk up from this file to find monorepo skills/ + const thisFile = fileURLToPath(import.meta.url); + let dir = dirname(thisFile); + for (let i = 0; i < 10; i++) { + const candidate = join(dir, "skills", "hyperframes"); + if ( + existsSync(join(candidate, "templates", "design-picker.html")) && + existsSync(join(candidate, "scripts", "build-design-picker.py")) + ) { + return { + templatesDir: join(candidate, "templates"), + scriptPath: join(candidate, "scripts", "build-design-picker.py"), + }; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + + return null; +} + +function hasPython3(): boolean { + try { + execFileSync("python3", ["--version"], { stdio: "ignore", timeout: 5000 }); + return true; + } catch { + return false; + } +} + +function buildPicker(opts: { + scriptPath: string; + templatePath: string; + templatesDir: string; + outputPath: string; + pickerData: unknown; + cwd: string; +}): void { + execFileSync( + "python3", + [ + opts.scriptPath, + "--template", + opts.templatePath, + "--templates-dir", + opts.templatesDir, + "--presentations-dir", + opts.templatesDir, + "--output", + opts.outputPath, + ], + { + input: JSON.stringify(opts.pickerData), + stdio: ["pipe", "inherit", "inherit"], + // Run from a neutral cwd so the build script doesn't auto-detect design.md + // and auto-advance the picker past the templates grid on load. + cwd: opts.cwd, + }, + ); +} + +async function findOpenPort(start: number): Promise { + const { createServer: makeServer } = await import("node:net"); + for (let p = start; p < start + 100; p++) { + const free = await new Promise((res) => { + const srv = makeServer(); + srv.once("error", () => res(false)); + srv.once("listening", () => srv.close(() => res(true))); + srv.listen(p, "127.0.0.1"); + }); + if (free) return p; + } + throw new Error(`No free port in range ${start}–${start + 100}`); +} + +function serveStatic(rootDir: string, presentationsDir: string, port: number): void { + // fallow-ignore-next-line complexity + const server = createServer(async (req, res) => { + try { + const url = req.url ?? "/"; + const cleanPath = url.split("?")[0] ?? "/"; + + // Route any */templates//* path to the skills presentations directory. + // The picker HTML uses relative iframe srcs like `templates//template.html`, + // which the browser resolves against `/.hyperframes/pick-design.html` — + // so both `/templates/...` and `/.hyperframes/templates/...` hit this handler. + const tplIdx = cleanPath.indexOf("/templates/"); + let filePath: string; + if (tplIdx >= 0) { + const rel = cleanPath.slice(tplIdx + "/templates/".length); + filePath = resolve(presentationsDir, rel); + if (!filePath.startsWith(resolve(presentationsDir))) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + } else { + filePath = resolve(rootDir, "." + cleanPath); + if (!filePath.startsWith(resolve(rootDir))) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + } + + const data = await readFile(filePath); + const mime = MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream"; + res.writeHead(200, { "content-type": mime, "cache-control": "no-store" }); + res.end(data); + } catch { + res.writeHead(404); + res.end("Not found"); + } + }); + server.listen(port, "127.0.0.1"); +} + +export default defineCommand({ + meta: { + name: "pick", + description: "Open the design picker in your browser to choose a template + create design.md", + }, + args: { + port: { type: "string", description: "Port to serve the picker on", default: "8723" }, + "build-only": { + type: "boolean", + description: "Build the picker HTML without serving it", + default: false, + }, + }, + // fallow-ignore-next-line complexity + async run({ args }) { + if (!isDesignPickerEnabled()) { + clack.log.error(c.error("Design picker is an experimental feature and is not enabled.")); + clack.log.info( + c.dim("Set ") + + c.accent("HYPERFRAMES_DESIGN_PICKER=1") + + c.dim(" to enable, then retry: ") + + c.accent("hyperframes pick"), + ); + return; + } + const cwd = process.cwd(); + + if (!hasPython3()) { + clack.log.error(c.error("python3 not found. Install Python 3 and retry.")); + return; + } + + const skills = findSkillsRoot(cwd); + if (!skills) { + clack.log.error(c.error("HyperFrames skills not found.")); + clack.log.info( + c.dim("Run ") + c.accent("hyperframes skills") + c.dim(" to install, then retry."), + ); + return; + } + + const templatePath = join(skills.templatesDir, "design-picker.html"); + const presentationsDir = join(skills.templatesDir, "presentations"); + const outDir = join(cwd, ".hyperframes"); + const outFile = join(outDir, "pick-design.html"); + const dataFile = join(outDir, "picker-data.json"); + + mkdirSync(outDir, { recursive: true }); + + // Use existing picker-data.json if present, else write default + let pickerData: unknown = DEFAULT_PICKER_DATA; + if (existsSync(dataFile)) { + try { + pickerData = JSON.parse(readFileSync(dataFile, "utf-8")); + } catch { + clack.log.warn(c.dim("picker-data.json invalid JSON — using defaults")); + } + } else { + writeFileSync(dataFile, JSON.stringify(DEFAULT_PICKER_DATA, null, 2)); + } + + const s = clack.spinner(); + s.start("Building design picker..."); + const buildCwd = mkdtempSync(join(tmpdir(), "hyperframes-pick-")); + try { + buildPicker({ + scriptPath: skills.scriptPath, + templatePath, + templatesDir: presentationsDir, + outputPath: outFile, + pickerData, + cwd: buildCwd, + }); + } catch (err) { + s.stop(c.error("Failed to build picker")); + console.error(c.dim((err as Error).message)); + return; + } + s.stop(c.success("Design picker built")); + + if (args["build-only"]) { + console.log(); + console.log(` ${c.dim("Built")} ${c.accent(outFile)}`); + console.log(); + return; + } + + const startPort = parseInt(args.port ?? "8723", 10); + const port = await findOpenPort(startPort); + serveStatic(cwd, presentationsDir, port); + + const url = `http://localhost:${port}/.hyperframes/pick-design.html`; + console.log(); + console.log(` ${c.dim("Picker")} ${c.accent(url)}`); + console.log(); + console.log(` ${c.dim("Press Ctrl+C to stop")}`); + console.log(); + + import("open").then((mod) => mod.default(url)).catch(() => {}); + + // Keep the process alive + await new Promise(() => {}); + }, +}); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index d9d0f7606..6c6981c0d 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -8,6 +8,7 @@ import { renderUsage } from "citty"; import type { CommandDef } from "citty"; import { c } from "./ui/colors.js"; import { VERSION } from "./version.js"; +import { isDesignPickerEnabled } from "./utils/env.js"; // ── Root-level command groups ────────────────────────────────────────────── interface Group { @@ -23,6 +24,12 @@ const GROUPS: Group[] = [ ["add", "Install a block or component from the registry"], ["capture", "Capture a website for video production"], ["catalog", "Browse and install blocks and components"], + ...(isDesignPickerEnabled() + ? ([["pick", "Open the design picker in your browser to create a design.md"]] as [ + string, + string, + ][]) + : []), ["preview", "Start the studio for previewing compositions"], ["publish", "Upload a project and get a stable public URL"], ["render", "Render a composition to MP4 or WebM"], @@ -51,10 +58,6 @@ const GROUPS: Group[] = [ ["upgrade", "Check for updates and show upgrade instructions"], ], }, - { - title: "Deploy", - commands: [["lambda", "Deploy and drive distributed renders on AWS Lambda"]], - }, { title: "AI & Integrations", commands: [ @@ -149,6 +152,7 @@ function formatExamples(examples: Example[]): string { } // ── Main showUsage override ──────────────────────────────────────────────── +// fallow-ignore-next-line complexity export async function showUsage(cmd: CommandDef, parent?: CommandDef): Promise { if (!parent) { console.log(renderRootHelp() + "\n"); diff --git a/packages/cli/src/utils/env.ts b/packages/cli/src/utils/env.ts index c6d22250e..cd61e03be 100644 --- a/packages/cli/src/utils/env.ts +++ b/packages/cli/src/utils/env.ts @@ -12,3 +12,14 @@ export function isDevMode(): boolean { return false; } } + +/** + * Feature flag — design picker (`hyperframes pick`, skill picker assets). + * Enabled when HYPERFRAMES_DESIGN_PICKER is set to "1" / "true" / "on" (case-insensitive), + * or whenever running in dev mode so contributors can iterate without setting env vars. + */ +export function isDesignPickerEnabled(): boolean { + if (isDevMode()) return true; + var v = (process.env.HYPERFRAMES_DESIGN_PICKER || "").trim().toLowerCase(); + return v === "1" || v === "true" || v === "on" || v === "yes"; +}