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, visiting: Set, ): 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, visiting: Set, ): 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(); const visiting = new Set(); 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