mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
feat(cli): hyperframes pick — open the design picker, gated behind feature flag
This commit is contained in:
+1
-31
@@ -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({
|
||||
|
||||
@@ -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<string, string> = {
|
||||
".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:
|
||||
"<div style='background:{{bg}};color:{{fg}};padding:{{pad}};font-family:\"{{bf}}\",sans-serif;'><div style='font-family:\"{{hf}}\",serif;font-size:48px;font-weight:{{hw}};line-height:1.05;'>{{prompt_headline}}</div><div style='font-size:18px;color:{{mt}};margin-top:12px;max-width:70%;line-height:1.5;'>{{prompt_sub}}</div></div>",
|
||||
},
|
||||
],
|
||||
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<number> {
|
||||
const { createServer: makeServer } = await import("node:net");
|
||||
for (let p = start; p < start + 100; p++) {
|
||||
const free = await new Promise<boolean>((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/<slug>/* path to the skills presentations directory.
|
||||
// The picker HTML uses relative iframe srcs like `templates/<slug>/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(() => {});
|
||||
},
|
||||
});
|
||||
@@ -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<void> {
|
||||
if (!parent) {
|
||||
console.log(renderRootHelp() + "\n");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user