diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 8a5abe86..a9fc286b 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -12,7 +12,7 @@ import { existsSync } from "node:fs"; import { resolve, relative } from "node:path"; import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core"; import { c } from "../ui/colors.js"; -import { installItem, resolveItem } from "../registry/index.js"; +import { installItem, resolveItemWithDeps } from "../registry/index.js"; import { DEFAULT_PROJECT_CONFIG, loadProjectConfig, @@ -106,58 +106,65 @@ export async function runAdd(opts: RunAddArgs): Promise { config = DEFAULT_PROJECT_CONFIG; } - // 2. Resolve the item from the registry. - let item: RegistryItem; + // 2. Resolve the item and its dependencies from the registry. + let items: RegistryItem[]; try { - item = await resolveItem(opts.name, { baseUrl: config.registry }); + items = await resolveItemWithDeps(opts.name, { baseUrl: config.registry }); } catch (err) { throw new AddError(err instanceof Error ? err.message : String(err), "unknown-item"); } - if (item.type === "hyperframes:example") { + const targetItem = items[items.length - 1]!; + if (targetItem.type === "hyperframes:example") { throw new AddError( - `"${item.name}" is an example — use \`hyperframes init --example ${item.name}\` instead.`, + `"${targetItem.name}" is an example — use \`hyperframes init --example ${targetItem.name}\` instead.`, "example-type", ); } - // 3. Remap targets per project config. - const remappedFiles = item.files.map((f) => ({ - ...f, - target: remapTarget(item, f.target, config.paths), - })); - const itemForInstall: RegistryItem = { ...item, files: remappedFiles }; + // 3. Install all items in order (dependencies first). + const allWritten: string[] = []; + for (const item of items) { + // Examples are installed by `init`, not `add`. Skip if they appear as deps + if (item.type === "hyperframes:example") continue; - // 4. Install — the installer validates every target before any write. - let written: string[]; - try { - const result = await installItem(itemForInstall, { - destDir: projectDir, - baseUrl: config.registry, - }); - written = result.written; - } catch (err) { - throw new AddError( - `Install failed: ${err instanceof Error ? err.message : String(err)}`, - "install-failed", - ); + const remappedFiles = item.files.map((f) => ({ + ...f, + target: remapTarget(item, f.target, config.paths), + })); + const itemForInstall: RegistryItem = { ...item, files: remappedFiles }; + + try { + const result = await installItem(itemForInstall, { + destDir: projectDir, + baseUrl: config.registry, + }); + allWritten.push(...result.written); + } catch (err) { + throw new AddError( + `Install failed for "${item.name}": ${err instanceof Error ? err.message : String(err)}`, + "install-failed", + ); + } } - // 5. Build include snippet + clipboard copy. + // 4. Build include snippet + clipboard copy for the TARGET item only. const primaryFile = - itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ?? - itemForInstall.files.find((f) => f.type === "hyperframes:composition") ?? - itemForInstall.files[0]; - const snippetTargetRel = primaryFile?.target ?? ""; - const snippet = buildSnippet(item, snippetTargetRel); + targetItem.files.find((f) => f.type === "hyperframes:snippet") ?? + targetItem.files.find((f) => f.type === "hyperframes:composition") ?? + targetItem.files[0]; + + // We need to remap the target again for the snippet building + const snippetTargetRel = remapTarget(targetItem, primaryFile?.target ?? "", config.paths); + const snippet = buildSnippet(targetItem, snippetTargetRel); const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false; return { ok: true, - name: item.name, - type: item.type, - typeDir: ITEM_TYPE_DIRS[item.type], - written, + name: targetItem.name, + type: targetItem.type, + typeDir: ITEM_TYPE_DIRS[targetItem.type], + written: allWritten, snippet, clipboardCopied, }; diff --git a/packages/cli/src/registry/index.ts b/packages/cli/src/registry/index.ts index c3a70261..59f7740c 100644 --- a/packages/cli/src/registry/index.ts +++ b/packages/cli/src/registry/index.ts @@ -5,7 +5,13 @@ export { fetchItemFile, } from "./remote.js"; -export { listRegistryItems, loadAllItems, resolveItem, type ResolveOptions } from "./resolver.js"; +export { + listRegistryItems, + loadAllItems, + resolveItem, + resolveItemWithDeps, + type ResolveOptions, +} from "./resolver.js"; export { installItem, diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts index 9db4e04d..38edec8c 100644 --- a/packages/cli/src/registry/resolver.ts +++ b/packages/cli/src/registry/resolver.ts @@ -63,27 +63,62 @@ export async function loadAllItems( return items; } +/** + * Resolve a single item by name along with all its transitive dependencies. + * Returns a topo-sorted list of items (dependencies first). + */ +export async function resolveItemWithDeps( + name: string, + options: ResolveOptions = {}, +): Promise { + const entries = await listRegistryItems(undefined, options); + const resolved = new Map(); + const visiting = new Set(); + + async function walk(itemName: string): Promise { + if (resolved.has(itemName)) return; + if (visiting.has(itemName)) { + throw new Error(`Circular dependency detected: ${itemName} is already being visited.`); + } + + visiting.add(itemName); + + const entry = entries.find((e) => e.name === itemName); + if (!entry) { + const available = entries.map((e) => e.name).join(", "); + throw new Error( + available.length > 0 + ? `Item "${itemName}" not found in registry. Available: ${available}` + : `Item "${itemName}" not found — registry unreachable or empty.`, + ); + } + + const item = await fetchItemManifest(entry.name, entry.type, options.baseUrl); + + // Resolve dependencies first (topo-sort) + if (item.registryDependencies && item.registryDependencies.length > 0) { + for (const depName of item.registryDependencies) { + await walk(depName); + } + } + + resolved.set(itemName, item); + visiting.delete(itemName); + } + + await walk(name); + return Array.from(resolved.values()); +} + /** * Resolve a single item by name. Throws if unknown or unreachable. - * - * TODO: walk registryDependencies transitively and return a topo-sorted - * list of items. Today examples have no deps so this returns a single item. - * Blocks and components will need transitive resolution once they ship with - * deps (seed items in Phase B). + * For transitive resolution, use `resolveItemWithDeps`. */ export async function resolveItem( name: string, options: ResolveOptions = {}, ): Promise { - const entries = await listRegistryItems(undefined, options); - const entry = entries.find((e) => e.name === name); - if (!entry) { - const available = entries.map((e) => e.name).join(", "); - throw new Error( - available.length > 0 - ? `Item "${name}" not found in registry. Available: ${available}` - : `Item "${name}" not found — registry unreachable or empty.`, - ); - } - return fetchItemManifest(entry.name, entry.type, options.baseUrl); + const items = await resolveItemWithDeps(name, options); + // The requested item is the last one in a topo-sorted list. + return items[items.length - 1]!; } diff --git a/packages/core/src/adapters/index.ts b/packages/core/src/adapters/index.ts index 254c28bd..d174674c 100644 --- a/packages/core/src/adapters/index.ts +++ b/packages/core/src/adapters/index.ts @@ -1,3 +1,5 @@ export type { FrameAdapter, FrameAdapterContext } from "./types"; export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./gsap"; export { createGSAPFrameAdapter } from "./gsap"; +export type { LottieAnimationLike, CreateLottieFrameAdapterOptions } from "./lottie"; +export { createLottieFrameAdapter } from "./lottie"; diff --git a/packages/core/src/adapters/lottie.ts b/packages/core/src/adapters/lottie.ts new file mode 100644 index 00000000..7da231b5 --- /dev/null +++ b/packages/core/src/adapters/lottie.ts @@ -0,0 +1,68 @@ +import type { FrameAdapter } from "./types"; + +interface LottieAnimationItem { + play: () => void; + pause: () => void; + goToAndStop: (value: number, isFrame: boolean) => void; + totalFrames: number; + frameRate: number; +} + +interface DotLottiePlayer { + play: () => void; + pause: () => void; + seek?: (percentage: number) => void; + setCurrentRawFrameValue?: (frame: number) => void; + totalFrames?: number; + frameRate?: number; + duration?: number; +} + +export type LottieAnimationLike = LottieAnimationItem | DotLottiePlayer; + +export interface CreateLottieFrameAdapterOptions { + id?: string; + fps: number; + animation: LottieAnimationLike; +} + +export function createLottieFrameAdapter(options: CreateLottieFrameAdapterOptions): FrameAdapter { + const { fps, animation } = options; + const adapterId = options.id ?? "lottie"; + + return { + id: adapterId, + init: () => { + animation.pause(); + }, + getDurationFrames: () => { + if ("totalFrames" in animation && typeof animation.totalFrames === "number") { + const lottieFps = animation.frameRate ?? 30; + const durationSeconds = animation.totalFrames / lottieFps; + return Math.max(0, Math.ceil(durationSeconds * fps)); + } + if ("duration" in animation && typeof animation.duration === "number") { + return Math.max(0, Math.ceil((animation.duration || 0) * fps)); + } + return 0; + }, + seekFrame: (frame: number) => { + const targetSeconds = Math.max(0, frame) / fps; + animation.pause(); + + if ("goToAndStop" in animation && typeof animation.goToAndStop === "function") { + animation.goToAndStop(targetSeconds * 1000, false); + } else if ( + "setCurrentRawFrameValue" in animation && + typeof animation.setCurrentRawFrameValue === "function" + ) { + const lottieFps = animation.frameRate ?? 30; + animation.setCurrentRawFrameValue(targetSeconds * lottieFps); + } else if ("seek" in animation && typeof animation.seek === "function") { + const duration = animation.duration ?? 1; + const percentage = Math.min(100, (targetSeconds / duration) * 100); + animation.seek(percentage); + } + }, + }; +} diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index bfd03ee7..459ec60a 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -28,9 +28,22 @@ export const ENCODER_PRESETS = { */ export function getEncoderPreset( quality: "draft" | "standard" | "high", - format: "mp4" | "webm" | "mov" = "mp4", -): { preset: string; quality: number; codec: "h264" | "vp9" | "prores"; pixelFormat: string } { + format: "mp4" | "webm" | "mov" | "gif" = "mp4", +): { + preset: string; + quality: number; + codec: "h264" | "vp9" | "prores" | "gif"; + pixelFormat: string; +} { const base = ENCODER_PRESETS[quality]; + if (format === "gif") { + return { + preset: "medium", // ignore for GIF + quality: base.quality, + codec: "gif", + pixelFormat: "rgb24", + }; + } if (format === "webm") { return { preset: base.preset === "ultrafast" ? "realtime" : "good", @@ -132,6 +145,11 @@ export function buildEncoderArgs( args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0"); args.push("-pix_fmt", pixelFormat); return [...args, "-y", outputPath]; + } else if (codec === "gif") { + // High-quality GIF generation using palettegen and paletteuse. + // split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse + args.push("-lavfi", "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"); + return [...args, "-y", outputPath]; } // BT.709 color space metadata — Chrome screenshots are sRGB which maps to bt709. diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index c6fb738f..a14f7bd8 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -72,7 +72,7 @@ export interface StreamingEncoderOptions { fps: number; width: number; height: number; - codec?: "h264" | "h265" | "vp9" | "prores"; + codec?: "h264" | "h265" | "vp9" | "prores" | "gif"; preset?: string; quality?: number; bitrate?: string; @@ -192,6 +192,10 @@ function buildStreamingArgs( args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0"); args.push("-pix_fmt", pixelFormat); return [...args, "-y", outputPath]; + } else if (codec === "gif") { + // High-quality GIF generation using palettegen and paletteuse. + args.push("-lavfi", "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"); + return [...args, "-y", outputPath]; } // BT.709 color space metadata — Chrome screenshots are sRGB which maps to bt709. diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 4480aff3..954f6236 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -99,8 +99,8 @@ export type RenderStatus = export interface RenderConfig { fps: 24 | 30 | 60; quality: "draft" | "standard" | "high"; - /** Output container format. WebM uses VP9+alpha, MOV uses ProRes 4444+alpha for transparency. */ - format?: "mp4" | "webm" | "mov"; + /** Output container format. WebM uses VP9+alpha, MOV uses ProRes 4444+alpha for transparency, GIF uses 256-color palette. */ + format?: "mp4" | "webm" | "mov" | "gif"; workers?: number; useGpu?: boolean; debug?: boolean; @@ -382,10 +382,11 @@ export async function executeRenderJob( const perfStages: Record = {}; const perfOutputPath = join(workDir, "perf-summary.json"); const cfg = { ...(job.config.producerConfig ?? resolveConfig()) }; - const outputFormat = (job.config.format ?? "mp4") as "mp4" | "webm" | "mov"; + const outputFormat = (job.config.format ?? "mp4") as "mp4" | "webm" | "mov" | "gif"; const isWebm = outputFormat === "webm"; const isMov = outputFormat === "mov"; - const needsAlpha = isWebm || isMov; + const isGif = outputFormat === "gif"; + const needsAlpha = isWebm || isMov || isGif; // Transparency requires screenshot mode — beginFrame doesn't support alpha channel if (needsAlpha) { cfg.forceScreenshot = true; @@ -801,7 +802,12 @@ export async function executeRenderJob( const workerCount = calculateOptimalWorkers(job.totalFrames!, job.config.workers, cfg); - const FORMAT_EXT: Record = { mp4: ".mp4", webm: ".webm", mov: ".mov" }; + const FORMAT_EXT: Record = { + mp4: ".mp4", + webm: ".webm", + mov: ".mov", + gif: ".gif", + }; const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4"; const videoOnlyPath = join(workDir, `video-only${videoExt}`); const preset = getEncoderPreset(job.config.quality, outputFormat); @@ -1091,7 +1097,10 @@ export async function executeRenderJob( const stage6Start = Date.now(); updateJobStatus(job, "assembling", "Assembling final video", 90, onProgress); - if (hasAudio) { + if (isGif) { + // GIF doesn't support audio and doesn't need faststart. + copyFileSync(videoOnlyPath, outputPath); + } else if (hasAudio) { const muxResult = await muxVideoWithAudio( videoOnlyPath, audioOutputPath, diff --git a/registry/blocks/code-terminal/code-terminal.html b/registry/blocks/code-terminal/code-terminal.html new file mode 100644 index 00000000..359d7546 --- /dev/null +++ b/registry/blocks/code-terminal/code-terminal.html @@ -0,0 +1,222 @@ + + + + + + Cinematic Code Terminal | Hyperframes Block + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
antigravity.ts
+
+
+
+
+
+
+ + + + +
+ + diff --git a/registry/blocks/code-terminal/registry-item.json b/registry/blocks/code-terminal/registry-item.json new file mode 100644 index 00000000..3be95f31 --- /dev/null +++ b/registry/blocks/code-terminal/registry-item.json @@ -0,0 +1,19 @@ +{ + "name": "code-terminal", + "type": "hyperframes:block", + "title": "Cinematic Code Terminal", + "description": "A high-fidelity macOS-style dev console for cinematic code showcases. Features frame-accurate typing animations, sapphire-dark aesthetics, and real-time Prism.js syntax highlighting.", + "dimensions": { + "width": 1920, + "height": 1080 + }, + "duration": 10, + "files": [ + { + "path": "code-terminal.html", + "target": "compositions/code-terminal.html", + "type": "hyperframes:composition" + } + ], + "registryDependencies": [] +} diff --git a/registry/registry.json b/registry/registry.json index 3129ba93..27a53025 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -202,6 +202,10 @@ { "name": "transitions-scale", "type": "hyperframes:block" + }, + { + "name": "code-terminal", + "type": "hyperframes:block" } ] }