mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
* refactor(engine): manage child process lifecycles * fix(engine): preserve child reaping after runtime errors * fix(engine): untrack child processes on exit
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
// fallow-ignore-file complexity
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
import { parseHTML } from "linkedom";
|
|
import { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "@hyperframes/core";
|
|
import { DEFAULT_VP9_CPU_USED, runFfmpeg } from "@hyperframes/engine";
|
|
import { isHttpUrl } from "../utils/urlDownloader.js";
|
|
|
|
const PREPARED_GIF_SUBDIR = "_animated_gif";
|
|
const CACHE_SCHEMA = "hfgif-v1";
|
|
|
|
export interface PreparedAnimatedGif {
|
|
id: string;
|
|
sourceSrc: string;
|
|
outputSrc: string;
|
|
outputPath: string;
|
|
metadata: AnimatedGifMetadata;
|
|
loop: boolean;
|
|
loopIterations: number;
|
|
padSeconds: number;
|
|
}
|
|
|
|
export interface PrepareAnimatedGifInputsOptions {
|
|
projectDir: string;
|
|
downloadDir: string;
|
|
outputDir?: string;
|
|
outputSrcPrefix?: string;
|
|
cacheDir?: string;
|
|
sourceAssets?: Map<string, string>;
|
|
timeoutMs?: number;
|
|
transcode?: (input: AnimatedGifTranscodeRequest) => Promise<void>;
|
|
}
|
|
|
|
export interface AnimatedGifTranscodeRequest {
|
|
inputPath: string;
|
|
outputPath: string;
|
|
args: string[];
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface PrepareAnimatedGifInputsResult {
|
|
html: string;
|
|
preparedAssets: Map<string, string>;
|
|
preparedGifs: PreparedAnimatedGif[];
|
|
}
|
|
|
|
function splitUrlSuffix(src: string): { basePath: string; suffix: string } {
|
|
const queryIdx = src.indexOf("?");
|
|
const hashIdx = src.indexOf("#");
|
|
if (queryIdx < 0 && hashIdx < 0) return { basePath: src, suffix: "" };
|
|
const cutIdx = queryIdx < 0 ? hashIdx : hashIdx < 0 ? queryIdx : Math.min(queryIdx, hashIdx);
|
|
return { basePath: src.slice(0, cutIdx), suffix: src.slice(cutIdx) };
|
|
}
|
|
|
|
function normalizeRelPath(path: string): string {
|
|
return path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
}
|
|
|
|
function hasGifExtension(src: string): boolean {
|
|
const { basePath } = splitUrlSuffix(src.trim().toLowerCase());
|
|
return basePath.endsWith(".gif");
|
|
}
|
|
|
|
function readLoopOverride(el: Element): boolean | null {
|
|
const raw = el.getAttribute("data-loop");
|
|
if (raw == null) return null;
|
|
const normalized = raw.trim().toLowerCase();
|
|
if (normalized === "" || normalized === "true" || normalized === "1" || normalized === "yes") {
|
|
return true;
|
|
}
|
|
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
|
return false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveGifSourcePath(
|
|
src: string,
|
|
options: Pick<PrepareAnimatedGifInputsOptions, "projectDir" | "downloadDir" | "sourceAssets">,
|
|
): string | null {
|
|
const trimmed = src.trim();
|
|
if (!trimmed || trimmed.startsWith("data:")) return null;
|
|
const { basePath } = splitUrlSuffix(trimmed);
|
|
const normalizedBase = normalizeRelPath(basePath);
|
|
|
|
const mapped =
|
|
options.sourceAssets?.get(trimmed) ??
|
|
options.sourceAssets?.get(basePath) ??
|
|
options.sourceAssets?.get(normalizedBase);
|
|
if (mapped && existsSync(mapped)) return mapped;
|
|
if (isHttpUrl(trimmed)) return null;
|
|
|
|
const projectRelative = basePath.startsWith("/") ? basePath.slice(1) : basePath;
|
|
const candidates = [
|
|
isAbsolute(basePath) ? basePath : resolve(options.projectDir, projectRelative),
|
|
resolve(options.downloadDir, normalizedBase),
|
|
];
|
|
|
|
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
}
|
|
|
|
function isUsableFile(path: string): boolean {
|
|
try {
|
|
const stat = statSync(path);
|
|
return stat.isFile() && stat.size > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function computePreparedGifHash(
|
|
bytes: Uint8Array,
|
|
loopIterations: number,
|
|
padSeconds: number,
|
|
): string {
|
|
return createHash("sha256")
|
|
.update(CACHE_SCHEMA)
|
|
.update("\0")
|
|
.update(String(loopIterations))
|
|
.update("\0")
|
|
.update(String(padSeconds))
|
|
.update("\0")
|
|
.update(bytes)
|
|
.digest("hex");
|
|
}
|
|
|
|
function resolveLoop(metadata: AnimatedGifMetadata, override: boolean | null): boolean {
|
|
if (override != null) return override;
|
|
return metadata.loopCount === 0;
|
|
}
|
|
|
|
/**
|
|
* The render pipeline seek-syncs <video> elements and does not honor the
|
|
* native `loop` attribute, so a 2s WebM inside a 10s clip would vanish after
|
|
* 2s. Prep-time knows the clip window, so looping is baked into the encoded
|
|
* file: enough `-stream_loop` iterations to cover the window, plus a
|
|
* clone-frame pad for finite-loop GIFs that hold their last frame.
|
|
*/
|
|
const MAX_LOOP_ITERATIONS = 1_000;
|
|
const MAX_PAD_SECONDS = 3_600;
|
|
|
|
function resolveCompositionDurationSeconds(document: Document): number | null {
|
|
let max: number | null = null;
|
|
for (const el of Array.from(document.querySelectorAll("[data-composition-id][data-duration]"))) {
|
|
const value = Number.parseFloat(el.getAttribute("data-duration") || "");
|
|
if (Number.isFinite(value) && value > 0) {
|
|
max = max == null ? value : Math.max(max, value);
|
|
}
|
|
}
|
|
return max;
|
|
}
|
|
|
|
function resolveClipWindowSeconds(img: Element, compositionDuration: number | null): number | null {
|
|
const durationRaw = img.getAttribute("data-duration");
|
|
if (durationRaw != null) {
|
|
const duration = Number.parseFloat(durationRaw);
|
|
if (Number.isFinite(duration) && duration > 0) return duration;
|
|
}
|
|
if (compositionDuration != null) {
|
|
const startRaw = Number.parseFloat(img.getAttribute("data-start") || "0");
|
|
const start = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : 0;
|
|
const window = compositionDuration - start;
|
|
return window > 0 ? window : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolvePreparedPlayback(
|
|
metadata: AnimatedGifMetadata,
|
|
loop: boolean,
|
|
windowSeconds: number | null,
|
|
): { loopIterations: number; padSeconds: number } {
|
|
const gifDuration = metadata.durationSeconds > 0 ? metadata.durationSeconds : null;
|
|
|
|
let loopIterations = 1;
|
|
if (loop) {
|
|
loopIterations =
|
|
windowSeconds != null && gifDuration != null
|
|
? Math.min(MAX_LOOP_ITERATIONS, Math.max(1, Math.ceil(windowSeconds / gifDuration)))
|
|
: 1;
|
|
} else if (metadata.loopCount != null && metadata.loopCount > 1) {
|
|
loopIterations = Math.min(MAX_LOOP_ITERATIONS, metadata.loopCount);
|
|
}
|
|
|
|
let padSeconds = 0;
|
|
if (windowSeconds != null && gifDuration != null) {
|
|
const covered = gifDuration * loopIterations;
|
|
if (windowSeconds > covered) {
|
|
padSeconds = Math.min(windowSeconds - covered, MAX_PAD_SECONDS);
|
|
}
|
|
}
|
|
|
|
return { loopIterations, padSeconds: Math.round(padSeconds * 1000) / 1000 };
|
|
}
|
|
|
|
export function buildAnimatedGifTranscodeArgs(input: {
|
|
inputPath: string;
|
|
outputPath: string;
|
|
loopIterations: number;
|
|
padSeconds?: number;
|
|
}): string[] {
|
|
const args = ["-hide_banner", "-loglevel", "error"];
|
|
if (input.loopIterations > 1) {
|
|
args.push("-stream_loop", String(input.loopIterations - 1));
|
|
}
|
|
args.push(
|
|
"-ignore_loop",
|
|
"1",
|
|
"-i",
|
|
input.inputPath,
|
|
"-an",
|
|
"-c:v",
|
|
"libvpx-vp9",
|
|
"-pix_fmt",
|
|
"yuva420p",
|
|
"-auto-alt-ref",
|
|
"0",
|
|
"-deadline",
|
|
"good",
|
|
"-cpu-used",
|
|
String(DEFAULT_VP9_CPU_USED),
|
|
"-crf",
|
|
"18",
|
|
"-b:v",
|
|
"0",
|
|
...(input.padSeconds && input.padSeconds > 0
|
|
? ["-vf", `tpad=stop_mode=clone:stop_duration=${input.padSeconds}`]
|
|
: []),
|
|
// Explicit muxer: the transcode writes to a `.tmp-<pid>-<ts>` path whose
|
|
// extension ffmpeg cannot infer the container from.
|
|
"-f",
|
|
"webm",
|
|
"-y",
|
|
input.outputPath,
|
|
);
|
|
return args;
|
|
}
|
|
|
|
async function runAnimatedGifTranscode(request: AnimatedGifTranscodeRequest): Promise<void> {
|
|
const timeout = request.timeoutMs ?? 300_000;
|
|
const result = await runFfmpeg(request.args, { timeout });
|
|
if (result.success) return;
|
|
if (result.terminationReason === "deadline") {
|
|
throw new Error(`Animated GIF transcode timed out after ${timeout}ms`);
|
|
}
|
|
throw (
|
|
result.error ??
|
|
new Error(`Animated GIF transcode failed (${result.exitCode}): ${result.stderr.slice(-500)}`)
|
|
);
|
|
}
|
|
|
|
async function ensurePreparedWebm(input: {
|
|
sourcePath: string;
|
|
cachePath: string;
|
|
outputPath: string;
|
|
loopIterations: number;
|
|
padSeconds: number;
|
|
timeoutMs?: number;
|
|
transcode?: (request: AnimatedGifTranscodeRequest) => Promise<void>;
|
|
}): Promise<void> {
|
|
if (!existsSync(dirname(input.cachePath))) {
|
|
mkdirSync(dirname(input.cachePath), { recursive: true });
|
|
}
|
|
if (!existsSync(dirname(input.outputPath))) {
|
|
mkdirSync(dirname(input.outputPath), { recursive: true });
|
|
}
|
|
|
|
if (!isUsableFile(input.cachePath)) {
|
|
const tmpPath = `${input.cachePath}.tmp-${process.pid}-${Date.now()}`;
|
|
const args = buildAnimatedGifTranscodeArgs({
|
|
inputPath: input.sourcePath,
|
|
outputPath: tmpPath,
|
|
loopIterations: input.loopIterations,
|
|
padSeconds: input.padSeconds,
|
|
});
|
|
const transcode = input.transcode ?? runAnimatedGifTranscode;
|
|
try {
|
|
await transcode({
|
|
inputPath: input.sourcePath,
|
|
outputPath: tmpPath,
|
|
args,
|
|
timeoutMs: input.timeoutMs,
|
|
});
|
|
if (!isUsableFile(tmpPath)) {
|
|
throw new Error("Animated GIF transcode produced an empty output");
|
|
}
|
|
if (!isUsableFile(input.cachePath)) {
|
|
renameSync(tmpPath, input.cachePath);
|
|
} else {
|
|
rmSync(tmpPath, { force: true });
|
|
}
|
|
} catch (error) {
|
|
rmSync(tmpPath, { force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (resolve(input.outputPath) !== resolve(input.cachePath) && !isUsableFile(input.outputPath)) {
|
|
copyFileSync(input.cachePath, input.outputPath);
|
|
}
|
|
}
|
|
|
|
function ensureElementId(el: Element, document: Document, fallbackIndex: number): string {
|
|
const existing = (el.getAttribute("id") || "").trim();
|
|
if (existing) return existing;
|
|
let next = fallbackIndex;
|
|
while (document.getElementById(`hf-gif-${next}`)) next += 1;
|
|
const id = `hf-gif-${next}`;
|
|
el.setAttribute("id", id);
|
|
return id;
|
|
}
|
|
|
|
function ensureTimingAttributes(video: Element): void {
|
|
if (!video.hasAttribute("data-start")) {
|
|
video.setAttribute("data-start", "0");
|
|
}
|
|
if (!video.hasAttribute("data-end")) {
|
|
const durationRaw = video.getAttribute("data-duration");
|
|
if (durationRaw != null) {
|
|
const start = Number.parseFloat(video.getAttribute("data-start") || "0");
|
|
const duration = Number.parseFloat(durationRaw);
|
|
if (Number.isFinite(start) && Number.isFinite(duration) && duration > 0) {
|
|
video.setAttribute("data-end", String(start + duration));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function replaceImageWithVideo(input: {
|
|
img: Element;
|
|
id: string;
|
|
outputSrc: string;
|
|
loop: boolean;
|
|
}): Element {
|
|
const document = input.img.ownerDocument;
|
|
const video = document.createElement("video");
|
|
for (const attr of Array.from(input.img.attributes)) {
|
|
if (attr.name === "src") continue;
|
|
video.setAttribute(attr.name, attr.value);
|
|
}
|
|
video.setAttribute("id", input.id);
|
|
video.setAttribute("src", input.outputSrc);
|
|
video.setAttribute("muted", "");
|
|
video.setAttribute("playsinline", "");
|
|
video.setAttribute("preload", "auto");
|
|
video.setAttribute("data-has-audio", "false");
|
|
video.setAttribute("data-hf-prepared-gif", "true");
|
|
if (input.loop) {
|
|
video.setAttribute("loop", "");
|
|
} else {
|
|
video.removeAttribute("loop");
|
|
}
|
|
ensureTimingAttributes(video);
|
|
input.img.replaceWith(video);
|
|
return video;
|
|
}
|
|
|
|
export async function prepareAnimatedGifInputs(
|
|
html: string,
|
|
options: PrepareAnimatedGifInputsOptions,
|
|
): Promise<PrepareAnimatedGifInputsResult> {
|
|
const outputDir = options.outputDir ?? join(options.downloadDir, PREPARED_GIF_SUBDIR);
|
|
const outputSrcPrefix = normalizeRelPath(options.outputSrcPrefix ?? PREPARED_GIF_SUBDIR);
|
|
const cacheDir = options.cacheDir ?? outputDir;
|
|
const { document } = parseHTML(html);
|
|
const preparedAssets = new Map<string, string>();
|
|
const preparedGifs: PreparedAnimatedGif[] = [];
|
|
const images = Array.from(document.querySelectorAll("img[src]"));
|
|
const compositionDuration = resolveCompositionDurationSeconds(document);
|
|
|
|
for (let i = 0; i < images.length; i++) {
|
|
const img = images[i]!;
|
|
const src = (img.getAttribute("src") || "").trim();
|
|
if (!hasGifExtension(src)) continue;
|
|
const sourcePath = resolveGifSourcePath(src, options);
|
|
if (!sourcePath) continue;
|
|
|
|
const bytes = readFileSync(sourcePath);
|
|
const metadata = parseAnimatedGifMetadata(bytes);
|
|
if (!metadata?.animated) continue;
|
|
|
|
const loop = resolveLoop(metadata, readLoopOverride(img));
|
|
const windowSeconds = resolveClipWindowSeconds(img, compositionDuration);
|
|
const { loopIterations, padSeconds } = resolvePreparedPlayback(metadata, loop, windowSeconds);
|
|
const hash = computePreparedGifHash(bytes, loopIterations, padSeconds);
|
|
const filename = `${CACHE_SCHEMA}-${hash.slice(0, 24)}.webm`;
|
|
const cachePath = join(cacheDir, filename);
|
|
const outputPath = join(outputDir, filename);
|
|
const outputSrc = `${outputSrcPrefix}/${filename}`;
|
|
|
|
await ensurePreparedWebm({
|
|
sourcePath,
|
|
cachePath,
|
|
outputPath,
|
|
loopIterations,
|
|
padSeconds,
|
|
timeoutMs: options.timeoutMs,
|
|
transcode: options.transcode,
|
|
});
|
|
|
|
const id = ensureElementId(img, document, i);
|
|
replaceImageWithVideo({ img, id, outputSrc, loop });
|
|
preparedAssets.set(outputSrc, outputPath);
|
|
preparedGifs.push({
|
|
id,
|
|
sourceSrc: src,
|
|
outputSrc,
|
|
outputPath,
|
|
metadata,
|
|
loop,
|
|
loopIterations,
|
|
padSeconds,
|
|
});
|
|
}
|
|
|
|
return {
|
|
html: preparedGifs.length > 0 ? document.toString() : html,
|
|
preparedAssets,
|
|
preparedGifs,
|
|
};
|
|
}
|