// 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; timeoutMs?: number; transcode?: (input: AnimatedGifTranscodeRequest) => Promise; } export interface AnimatedGifTranscodeRequest { inputPath: string; outputPath: string; args: string[]; timeoutMs?: number; } export interface PrepareAnimatedGifInputsResult { html: string; preparedAssets: Map; 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, ): 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