Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 42 additions & 35 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -106,58 +106,65 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
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 <dir> --example ${item.name}\` instead.`,
`"${targetItem.name}" is an example — use \`hyperframes init <dir> --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,
};
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 51 additions & 16 deletions packages/cli/src/registry/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegistryItem[]> {
const entries = await listRegistryItems(undefined, options);
const resolved = new Map<string, RegistryItem>();
const visiting = new Set<string>();

async function walk(itemName: string): Promise<void> {
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<RegistryItem> {
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]!;
}
2 changes: 2 additions & 0 deletions packages/core/src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -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";
68 changes: 68 additions & 0 deletions packages/core/src/adapters/lottie.ts
Original file line number Diff line number Diff line change
@@ -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);
}
},
};
}
22 changes: 20 additions & 2 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion packages/engine/src/services/streamingEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 15 additions & 6 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -382,10 +382,11 @@ export async function executeRenderJob(
const perfStages: Record<string, number> = {};
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;
Expand Down Expand Up @@ -801,7 +802,12 @@ export async function executeRenderJob(

const workerCount = calculateOptimalWorkers(job.totalFrames!, job.config.workers, cfg);

const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
const FORMAT_EXT: Record<string, string> = {
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);
Expand Down Expand Up @@ -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,
Expand Down
Loading