import path from "path"; import { execSync } from "child_process"; import fs from "fs"; import { Plugin } from "./plugin.js"; export class HookPlugin extends Plugin { constructor(options) { super("hook", options); } async applyPostrender(originalManuscript, jobId, outFiles) { super.applyPostrender(originalManuscript, jobId, outFiles); const hook = originalManuscript.hook || originalManuscript.hook || originalManuscript.hookFile || originalManuscript.hook?.file ? originalManuscript.hook : null; // normalize hook object const hookObj = originalManuscript.hook || (originalManuscript.hookFile ? { file: originalManuscript.hookFile } : null); if (!hookObj || !hookObj.file) { this.log("no hook file specified, skipping"); return; } // find the final output file in outFiles const finalOut = (outFiles || []).find(f => f.includes("final.mp4")); if (!finalOut) { this.log("no final.mp4 found in outFiles", outFiles); return; } // Use mediaPathFlatten to turn media paths into local file paths const mediaFlatten = (p) => { try { return super.mediaPathFlatten(p); } catch (e) { return path.join("public", path.basename(p)); } }; const finalPath = finalOut const hookPath = mediaFlatten(hookObj.file); if (!fs.existsSync(finalPath)) { this.log("final file does not exist:", finalPath); return; } if (!fs.existsSync(hookPath)) { this.log("hook file does not exist:", hookPath); return; } // probe final and hook to get resolution and duration const probe = (file) => { try { const out = execSync(`ffprobe -v error -select_streams v:0 -show_entries stream=width,height,duration -of csv=p=0 "${file}"`, { encoding: "utf8" }); const [width, height, duration] = out.trim().split(',').map(s => s ? s.trim() : s); return { width: parseInt(width, 10), height: parseInt(height, 10), duration: parseFloat(duration) }; } catch (e) { this.log("ffprobe failed", e.message); return null; } }; const finalMeta = probe(finalPath); const hookMeta = probe(hookPath); if (!finalMeta || !hookMeta) { this.log("could not probe media metadata, skipping"); return; } // build temporary clipped/scaled hook file if needed const tmpDir = path.dirname(finalPath); const tmpHookPath = path.join(tmpDir, `hook-${Date.now()}.mp4`); const targetDuration = hookObj.durationSec ? Number(hookObj.durationSec) : null; // ffmpeg filter chain: scale to match final resolution, trim to duration if provided let filters = []; filters.push(`scale=${finalMeta.width}:${finalMeta.height}:force_original_aspect_ratio=decrease`); // pad to ensure exact resolution filters.push(`pad=${finalMeta.width}:${finalMeta.height}:(ow-iw)/2:(oh-ih)/2`); const filterChain = filters.join(","); // build ffmpeg command: take hook, scale/pad, optionally trim, and re-encode to mp4 with standard audio params let ffmpegCmd = `ffmpeg -y -i "${hookPath}" `; if (targetDuration) { ffmpegCmd += `-t ${targetDuration} `; } // ensure consistent audio (48kHz stereo) and pixel format ffmpegCmd += `-vf "${filterChain}" -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 -movflags +faststart "${tmpHookPath}"`; try { this.log("processing hook with ffmpeg", { cmd: ffmpegCmd }); execSync(ffmpegCmd, { stdio: "inherit" }); } catch (e) { this.log("ffmpeg hook processing failed", e.message); return; } // Now prepend tmpHookPath to finalPath -> create a new file tmpFinal const tmpFinal = path.join(tmpDir, `final-with-hook-${Date.now()}.mp4`); // Always use filter_complex concat and re-encode audio to avoid codec/format mismatch issues const concatReencodeCmd = `ffmpeg -y -i "${tmpHookPath}" -i "${finalPath}" -filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0]concat=n=2:v=1:a=1[outv][outa]" -map "[outv]" -map "[outa]" -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 "${tmpFinal}"`; try { this.log("concatenating (re-encode) files", { cmd: concatReencodeCmd }); execSync(concatReencodeCmd, { stdio: "inherit" }); } catch (e) { this.log("ffmpeg concat re-encode failed", e.message); return; } // replace original final file with tmpFinal try { fs.copyFileSync(tmpFinal, finalPath); this.log("replaced final file with hooked final", finalPath); } catch (e) { this.log("failed to replace final file", e.message); return; } finally { // cleanup temp files try { fs.unlinkSync(tmpHookPath); } catch (e) { } try { fs.unlinkSync(tmpFinal); } catch (e) { } } } }