Files
hyperframes/packages/cli/src/commands/compositions.ts
T

287 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineCommand } from "citty";
import { parseNumeric, parseStartExpression } from "@hyperframes/core";
import type { Example } from "./_examples.js";
import { existsSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
export const examples: Example[] = [
["List compositions in the current project", "hyperframes compositions"],
["Output as JSON", "hyperframes compositions --json"],
];
import { c } from "../ui/colors.js";
import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";
interface CompositionInfo {
id: string;
duration: number;
width: number;
height: number;
elementCount: number;
source?: string;
}
const NON_RENDERED_TAGS = new Set(["script", "style", "link", "meta", "template"]);
function countRenderableDescendants(root: Element): number {
return Array.from(root.querySelectorAll("*")).filter(
(el) => !NON_RENDERED_TAGS.has(el.tagName.toLowerCase()),
).length;
}
function estimateDurationFromScripts(root: ParentNode): number {
let duration = 0;
for (const script of Array.from(root.querySelectorAll("script"))) {
const content = script.textContent ?? "";
const durationPattern = /\bduration\s*:\s*(\d+(?:\.\d+)?)/g;
let match: RegExpExecArray | null;
while ((match = durationPattern.exec(content)) !== null) {
const value = Number.parseFloat(match[1] ?? "");
if (Number.isFinite(value) && value > duration) {
duration = value;
}
}
}
return duration;
}
function findReferenceTargetEl(doc: Document, refId: string): Element | null {
return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`);
}
function resolveStart(
doc: Document,
el: Element,
startCache: Map<Element, number>,
visiting: Set<Element>,
): number {
const cached = startCache.get(el);
if (cached !== undefined) return cached;
if (visiting.has(el)) return 0;
visiting.add(el);
try {
const expression = parseStartExpression(el.getAttribute("data-start"));
if (!expression) {
startCache.set(el, 0);
return 0;
}
if (expression.kind === "absolute") {
const value = Math.max(0, expression.value);
startCache.set(el, value);
return value;
}
const target = findReferenceTargetEl(doc, expression.refId);
if (!target) {
startCache.set(el, 0);
return 0;
}
const targetStart = resolveStart(doc, target, startCache, visiting);
const targetDuration = resolveReferencedDuration(doc, target, startCache, visiting);
const resolved =
targetDuration != null && targetDuration > 0
? Math.max(0, targetStart + targetDuration + expression.offset)
: Math.max(0, targetStart + expression.offset);
startCache.set(el, resolved);
return resolved;
} finally {
visiting.delete(el);
}
}
function resolveReferencedDuration(
doc: Document,
el: Element,
startCache: Map<Element, number>,
visiting: Set<Element>,
): number | null {
const durationAttr = parseNumeric(el.getAttribute("data-duration"));
if (durationAttr != null && durationAttr > 0) return durationAttr;
const endAttr = parseNumeric(el.getAttribute("data-end"));
if (endAttr != null) {
const start = resolveStart(doc, el, startCache, visiting);
const delta = endAttr - start;
if (Number.isFinite(delta) && delta > 0) return delta;
}
return null;
}
export function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const compositionDivs = doc.querySelectorAll("[data-composition-id]");
const compositions: CompositionInfo[] = [];
const startCache = new Map<Element, number>();
const visiting = new Set<Element>();
compositionDivs.forEach((div) => {
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;
let elementCount = 0;
timedChildren.forEach((el) => {
elementCount++;
const start = resolveStart(doc, el, startCache, visiting);
const endAttr = el.getAttribute("data-end");
const durationAttr = el.getAttribute("data-duration");
let end: number;
if (endAttr) {
end = parseFloat(endAttr);
} else if (durationAttr) {
end = start + parseFloat(durationAttr);
} else {
end = start + 5;
}
if (end > maxEnd) {
maxEnd = end;
}
});
compositions.push({
id,
duration: maxEnd,
width,
height,
elementCount,
});
});
return compositions;
}
export function parseSubComposition(
html: string,
fallbackId: string,
fallbackWidth: number,
fallbackHeight: number,
): CompositionInfo {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const template = doc.querySelector("template");
const searchDocument = template?.content ?? doc;
// Sub-compositions may use <template> wrappers or direct divs
const compDiv = searchDocument.querySelector("[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 ?? searchDocument;
const timedChildren = searchRoot.querySelectorAll("[data-start], .clip, .caption-group");
let elementCount = timedChildren.length;
if (elementCount === 0 && compDiv) {
elementCount = countRenderableDescendants(compDiv);
}
// 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]");
const startCache = new Map<Element, number>();
const visiting = new Set<Element>();
timedEls.forEach((el) => {
elementCount = Math.max(elementCount, timedEls.length);
const start = resolveStart(doc, el, startCache, visiting);
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;
}
});
}
if (duration <= 0) {
duration = estimateDurationFromScripts(searchRoot);
}
return { id, duration, width, height, elementCount };
}
export default defineCommand({
meta: { name: "compositions", description: "List all compositions in a project" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) {
const project = resolveProject(args.dir);
const html = readFileSync(project.indexPath, "utf-8");
ensureDOMParser();
const compositions = parseCompositions(html, dirname(project.indexPath));
// --json must always emit a machine-readable envelope, including the empty
// case — check it before the human "no compositions" message on stdout.
if (args.json) {
console.log(JSON.stringify(withMeta({ compositions }), null, 2));
return;
}
if (compositions.length === 0) {
console.log(`${c.success("◇")} ${c.accent(project.name)} — no compositions found`);
return;
}
const compositionLabel =
compositions.length === 1 ? "1 composition" : `${compositions.length} compositions`;
console.log(
`${c.success("◇")} ${c.accent(project.name)} ${c.dim("—")} ${c.dim(compositionLabel)}`,
);
console.log();
// Calculate padding for alignment
const maxIdLen = compositions.reduce((max, comp) => Math.max(max, comp.id.length), 0);
for (const comp of compositions) {
const id = c.accent(comp.id.padEnd(maxIdLen));
const duration = c.bold(`${comp.duration.toFixed(1)}s`);
const resolution = c.dim(`${comp.width}×${comp.height}`);
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}${source}`);
}
},
});