Spaces:
Running
Running
| 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) { } | |
| } | |
| } | |
| } | |