mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
287 lines
9.3 KiB
TypeScript
287 lines
9.3 KiB
TypeScript
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}`);
|
||
}
|
||
},
|
||
});
|