File size: 4,995 Bytes
b1a7870
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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) { }
    }
  }
}