File size: 5,989 Bytes
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
import { promises as fs } from "node:fs"
import { writeFile, readFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"

import ffmpeg from "fluent-ffmpeg"
import { v4 as uuidv4 } from "uuid"

import { getMediaInfo } from "./getMediaInfo.mts"

export async function createVideoFromFrames({
  inputFramesDirectory,
  framesFilePattern,
  outputVideoPath,
  framesPerSecond = 25,

  // there isn't a lot of advantage for us to add film grain because:
  // 1. I actually can't tell the different, probably because it's in HD, and so tiny
  // 2. We want a neat "4K video from the 2020" look, not a quality from 30 years ago
  // 3. grain has too much entropy and cannot be compressed, so it multiplies by 5 the size weight
  grainAmount = 0, // Optional parameter for film grain (eg. 10)

  inputVideoToUseAsAudio, // Optional parameter for audio input (need to be a mp4, but it can be a base64 data URI or a file path)

  debug = false,

  asBase64 = false,
}: {
  inputFramesDirectory: string;

  // the ffmpeg file pattern to use
  framesFilePattern?: string;

  outputVideoPath?: string;
  framesPerSecond?: number;
  grainAmount?: number; // Values can range between 0 and higher for the desired amount
  inputVideoToUseAsAudio?: string; //  Optional parameter for audio input (need to be a mp4, but it can be a base64 data URI or a file path)
  debug?: boolean;
  asBase64?: boolean;
}): Promise<string> {
  // Ensure the input directory exists
  await fs.access(inputFramesDirectory);


  // Construct the input frame pattern
  const inputFramePattern = path.join(inputFramesDirectory, framesFilePattern);


  // Create a temporary working directory
  const tempDir = path.join(os.tmpdir(), uuidv4());
  await fs.mkdir(tempDir);

   
  let inputVideoToUseAsAudioFilePath = "";
  if (inputVideoToUseAsAudio.startsWith('data:')) {
    // Extract the base64 content and decode it to a temporary file
    const base64Content = inputVideoToUseAsAudio.split(';base64,').pop();
    if (!base64Content) {
      throw new Error('Invalid base64 input provided');
    }
    inputVideoToUseAsAudioFilePath = path.join(tempDir, `${uuidv4()}_audio_input.mp4`);
    await writeFile(inputVideoToUseAsAudioFilePath, base64Content, 'base64');
  } else {
    inputVideoToUseAsAudioFilePath = inputVideoToUseAsAudio;
  }

  if (debug) {
    console.log("      createVideoFromFraes(): inputVideoToUseAsAudioFilePath = ", inputVideoToUseAsAudioFilePath)
  }


  let canUseInputVideoForAudio = false
  // Also, if provided, check that the audio source file exists
  if (inputVideoToUseAsAudioFilePath) {
    try {
      await fs.access(inputVideoToUseAsAudioFilePath)
      const info = await getMediaInfo(inputVideoToUseAsAudioFilePath)
      if (info.hasAudio) {
        canUseInputVideoForAudio = true
      }
    } catch (err) {
      if (debug) {
        console.log("      createVideoFromFrames(): warning: input video has no audio, so we are not gonna use that")
      }
    }
  }

  const outputVideoFilePath = outputVideoPath ?? path.join(tempDir, `${uuidv4()}.mp4`);

  if (debug) {
    console.log("      createVideoFromFrames(): outputOptions:", [
      // by default ffmpeg doesn't tell us why it fails to convet
      // so we need to force it to spit everything out
      "-loglevel", "debug",

      "-pix_fmt", "yuv420p",
      "-c:v", "libx264",
      "-r", `${framesPerSecond}`,

      // from ffmpeg doc: "Consider 17 or 18 to be visually lossless or nearly so; 
      // it should look the same or nearly the same as the input."
      "-crf", "17",
    ])
  }

  return new Promise<string>((resolve, reject) => {
    const command = ffmpeg()
      .input(inputFramePattern)
      .inputFPS(framesPerSecond)
      .outputOptions([
        // by default ffmpeg doesn't tell us why it fails to convet
        // so we need to force it to spit everything out
        "-loglevel", "debug",

        "-pix_fmt", "yuv420p",
        "-c:v", "libx264",
        "-r", `${framesPerSecond}`,
        "-crf", "18",
      ]);

    
    // If an input video for audio is provided, add it as an input for the ffmpeg command
    if (canUseInputVideoForAudio) {
      if (debug) {
       console.log("      createVideoFromFrames(): adding audio as input:", inputVideoToUseAsAudioFilePath)
      }
      command.addInput(inputVideoToUseAsAudioFilePath);
      command.outputOptions([
        "-map", "0:v", // Map video from the frames
        "-map", "1:a", // Map audio from the input video
        "-shortest"    // Ensure output video duration is the shortest of the combined inputs
      ]);
    }

    // Apply grain effect using the geq filter if grainAmount is specified
    if (grainAmount != null && grainAmount > 0) {
      if (debug) {
        console.log("      createVideoFromFrames(): adding grain:", grainAmount)
      }
      command.complexFilter([
        {
          filter: "geq",
          options: `lum='lum(X,Y)':cr='cr(X,Y)+(random(1)-0.5)*${grainAmount}':cb='cb(X,Y)+(random(1)-0.5)*${grainAmount}'`
        }
      ]);
    }

    command.save(outputVideoFilePath)
      .on("error", (err) => reject(err))
      .on("end", async () => {
        if (debug) {
          console.log("      createVideoFromFrames(): outputVideoFilePath: ", outputVideoFilePath)
        }
        if (!asBase64) {
          resolve(outputVideoFilePath)
          return
        }
        // Convert the output file to a base64 string
        try {
          const videoBuffer = await readFile(outputVideoFilePath);
          const videoBase64 = `data:video/mp4;base64,${videoBuffer.toString('base64')}`;
          console.log("      createVideoFromFrames(): output base64: ", videoBase64.slice(0, 120))
          resolve(videoBase64);
        } catch (error) {
          reject(new Error(`Error loading the video file: ${error}`));
        } finally {
          // Clean up temporary files
          await fs.rm(tempDir, { recursive: true });
        }
      });
  });
}