shiveshnavin's picture
Add hooks
b1a7870
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) { }
}
}
}