Skip to content
Merged
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
79 changes: 75 additions & 4 deletions packages/cli/src/commands/compositions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";

export const examples: Example[] = [
["List compositions in the current project", "hyperframes compositions"],
Expand All @@ -17,9 +18,10 @@ interface CompositionInfo {
width: number;
height: number;
elementCount: number;
source?: string;
}

function parseCompositions(html: string): CompositionInfo[] {
function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");

Expand All @@ -30,6 +32,18 @@ function parseCompositions(html: string): CompositionInfo[] {
const id = div.getAttribute("data-composition-id") ?? "unknown";
const width = parseInt(div.getAttribute("data-width") ?? "1920", 10);
const height = parseInt(div.getAttribute("data-height") ?? "1080", 10);
const compositionSrc = div.getAttribute("data-composition-src");

// If this references an external sub-composition, parse that file
if (compositionSrc) {
const subPath = resolve(baseDir, compositionSrc);
if (existsSync(subPath)) {
const subHtml = readFileSync(subPath, "utf-8");
const subInfo = parseSubComposition(subHtml, id, width, height);
compositions.push({ ...subInfo, source: compositionSrc });
return;
}
}

const timedChildren = div.querySelectorAll("[data-start]");
let maxEnd = 0;
Expand Down Expand Up @@ -67,6 +81,62 @@ function parseCompositions(html: string): CompositionInfo[] {
return compositions;
}

function parseSubComposition(
html: string,
fallbackId: string,
fallbackWidth: number,
fallbackHeight: number,
): CompositionInfo {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");

// Sub-compositions may use <template> wrappers or direct divs
const compDiv =
doc.querySelector("[data-composition-id]") ??
doc.querySelector("template [data-composition-id]");

const id = compDiv?.getAttribute("data-composition-id") ?? fallbackId;
const width = parseInt(compDiv?.getAttribute("data-width") ?? String(fallbackWidth), 10);
const height = parseInt(compDiv?.getAttribute("data-height") ?? String(fallbackHeight), 10);

// Count timed elements inside the sub-composition
const searchRoot = compDiv ?? doc;
const timedChildren = searchRoot.querySelectorAll("[data-start], .clip, .caption-group");
let elementCount = timedChildren.length;

// Parse duration from the composition's own data-duration attribute
let duration = 0;
const durationAttr = compDiv?.getAttribute("data-duration");
if (durationAttr && !durationAttr.startsWith("__")) {
duration = parseFloat(durationAttr) || 0;
}

// Also check timed children for max end time
if (compDiv) {
const timedEls = compDiv.querySelectorAll("[data-start]");
timedEls.forEach((el) => {
elementCount = Math.max(elementCount, timedEls.length);
const start = parseFloat(el.getAttribute("data-start") ?? "0");
const endAttr = el.getAttribute("data-end");
const durAttr = el.getAttribute("data-duration");

let end: number;
if (endAttr) {
end = parseFloat(endAttr);
} else if (durAttr) {
end = start + parseFloat(durAttr);
} else {
end = start + 5;
}
if (end > duration) {
duration = end;
}
});
}

return { id, duration, width, height, elementCount };
}

export default defineCommand({
meta: { name: "compositions", description: "List all compositions in a project" },
args: {
Expand All @@ -78,7 +148,7 @@ export default defineCommand({
const html = readFileSync(project.indexPath, "utf-8");

ensureDOMParser();
const compositions = parseCompositions(html);
const compositions = parseCompositions(html, dirname(project.indexPath));

if (compositions.length === 0) {
console.log(`${c.success("◇")} ${c.accent(project.name)} — no compositions found`);
Expand Down Expand Up @@ -107,8 +177,9 @@ export default defineCommand({
const elements = c.dim(
`${comp.elementCount} ${comp.elementCount === 1 ? "element" : "elements"}`,
);
const source = comp.source ? c.dim(` ← ${comp.source}`) : "";

console.log(` ${id} ${duration} ${resolution} ${elements}`);
console.log(` ${id} ${duration} ${resolution} ${elements}${source}`);
}
},
});
16 changes: 8 additions & 8 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const examples: Example[] = [
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
["Parallel rendering with 4 workers", "hyperframes render --workers 4 --output fast.mp4"],
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
];
import { cpus, freemem } from "node:os";
import { resolve, dirname, join } from "node:path";
Expand All @@ -28,9 +28,9 @@ const VALID_FORMAT = new Set(["mp4", "webm"]);

const CPU_CORE_COUNT = cpus().length;

/** Half of CPU cores, capped at 4. Each worker spawns a Chrome process (~256 MB). */
/** 3/4 of CPU cores, capped at 8. Each worker spawns a Chrome process (~256 MB). */
function defaultWorkerCount(): number {
return Math.max(1, Math.min(Math.floor(CPU_CORE_COUNT / 2), 4));
return Math.max(1, Math.min(Math.floor((CPU_CORE_COUNT * 3) / 4), 8));
}

export default defineCommand({
Expand Down Expand Up @@ -66,8 +66,8 @@ export default defineCommand({
workers: {
type: "string",
description:
"Parallel render workers (1-8 or 'auto'). Default: half your CPU cores, max 4. " +
"Each worker launches a separate Chrome process.",
"Parallel render workers (number or 'auto'). Default: auto. " +
"Each worker launches a separate Chrome process (~256 MB RAM).",
},
docker: {
type: "boolean",
Expand Down Expand Up @@ -123,8 +123,8 @@ export default defineCommand({
let workers: number | undefined;
if (args.workers != null && args.workers !== "auto") {
const parsed = parseInt(args.workers, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 8) {
errorBox("Invalid workers", `Got "${args.workers}". Must be 1-8 or "auto".`);
if (isNaN(parsed) || parsed < 1) {
errorBox("Invalid workers", `Got "${args.workers}". Must be a positive number or "auto".`);
process.exit(1);
}
workers = parsed;
Expand Down Expand Up @@ -155,7 +155,7 @@ export default defineCommand({
const workerLabel =
args.workers != null
? `${workerCount} workers`
: `${workerCount} workers (auto \u2014 half of ${CPU_CORE_COUNT} cores)`;
: `${workerCount} workers (auto ${CPU_CORE_COUNT} cores detected)`;
console.log("");
console.log(
c.accent("\u25C6") +
Expand Down
31 changes: 23 additions & 8 deletions packages/cli/src/commands/upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import * as clack from "@clack/prompts";
import { execSync } from "node:child_process";
import { c } from "../ui/colors.js";

export const examples: Example[] = [
["Check for updates interactively", "hyperframes upgrade"],
["Check for updates without prompting", "hyperframes upgrade --check"],
["Show upgrade commands directly", "hyperframes upgrade --yes"],
["Upgrade non-interactively", "hyperframes upgrade --yes"],
];
import { VERSION } from "../version.js";
import { checkForUpdate, withMeta } from "../utils/updateCheck.js";
Expand Down Expand Up @@ -66,12 +67,26 @@ export default defineCommand({
}
}

console.log();
console.log(` ${c.accent("npm install -g hyperframes@" + result.latest)}`);
console.log(` ${c.dim("or")}`);
console.log(` ${c.accent("npx hyperframes@" + result.latest + " --version")}`);
console.log();

clack.outro(c.success("Run one of the commands above to upgrade."));
const installCmd = `npm install -g hyperframes@${result.latest}`;
if (autoYes) {
console.log();
console.log(` ${c.dim("Running:")} ${c.accent(installCmd)}`);
console.log();
try {
execSync(installCmd, { stdio: "inherit" });
clack.outro(c.success(`Upgraded to v${result.latest}`));
} catch {
clack.outro(c.dim("Install failed. Try running manually:"));
console.log(` ${c.accent(installCmd)}`);
process.exitCode = 1;
}
} else {
console.log();
console.log(` ${c.accent(installCmd)}`);
console.log(` ${c.dim("or")}`);
console.log(` ${c.accent("npx hyperframes@" + result.latest + " --version")}`);
console.log();
clack.outro(c.success("Run one of the commands above to upgrade."));
}
},
});
95 changes: 0 additions & 95 deletions packages/cli/src/templates/blank/compositions/captions.html

This file was deleted.

19 changes: 9 additions & 10 deletions packages/cli/src/templates/blank/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,20 @@
data-volume="1"
></audio>

<div
id="captions-comp"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-duration="__VIDEO_DURATION__"
data-track-index="3"
data-width="1920"
data-height="1080"
></div>
<!--
ANIMATION PATTERN: The clip div controls timing/visibility.
Always put your content in a CHILD element and animate THAT.

<div class="clip" ...> ← timing only, don't animate this
<div id="my-title">...</div> ← animate this with GSAP
</div>
-->
</div>

<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// tl.from("#my-title", { opacity: 0, y: -50, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body>
Expand Down
Loading
Loading