File size: 5,598 Bytes
3165afb
 
 
dde59e8
3165afb
 
2ed47da
 
 
 
 
 
 
 
 
 
 
a25b190
2ed47da
 
 
 
 
 
 
 
 
 
 
 
 
a25b190
4914f1f
2ed47da
 
 
 
 
 
 
 
 
 
 
a25b190
2ed47da
 
 
 
f26d0ef
 
2ed47da
 
 
f26d0ef
2ed47da
 
f26d0ef
 
 
 
 
 
 
15f63f6
 
f26d0ef
 
 
 
 
 
059a558
f26d0ef
 
 
 
059a558
f26d0ef
 
 
 
2ed47da
dde59e8
f26d0ef
 
2ed47da
f26d0ef
cde398b
f26d0ef
 
2ed47da
cde398b
 
 
565de77
a6123d1
 
8aa24de
565de77
a6123d1
cde398b
059a558
565de77
 
 
 
 
 
 
 
 
cde398b
 
 
 
 
 
 
 
 
 
 
 
565de77
cde398b
565de77
059a558
 
cde398b
f26d0ef
 
8aa24de
 
2ed47da
 
 
f26d0ef
 
2ed47da
 
 
cde398b
f26d0ef
2ed47da
f26d0ef
2ed47da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { rm, writeFile, readFile } from "node:fs/promises"
import path from "node:path"

import { v4 as uuidv4 } from "uuid"
import ffmpeg from "fluent-ffmpeg"
import { getRandomDirectory } from "@aitube/io"

/**
 * Converts an image in Base64 format to a video encoded in Base64.
 * 
 * @param inputImageInBase64 - The input image encoded in Base64.
 * @param outputVideoFormat - Optional. Format of the output video (default is "mp4").
 * @param outputVideoDurationInMs - Optional. Duration of the video in milliseconds (default is 1000ms).
 * @param codec - Optional. Codec used for video coding. Defaults differ based on `outputVideoFormat`.
 * @param width - Optional. Width of the output video.
 * @param height - Optional. Height of the output video.
 * @param fps - Optional. Frame rate of the output video.
 * @param zoomInRatePerSecond - Optional. Zoom-in rate (by default 0.6, or which would zoom by 3% over 5 seconds)
 * 
 * @returns - A promise that resolves to the video as a Base64 encoded string.
 */
export async function imageToVideoBase64({
  inputImageInBase64,
  outputFilePath,
  outputDir,
  clearOutputDirAtTheEnd = true,
  outputVideoFormat = "mp4",
  outputVideoDurationInMs = 1000,
  codec = outputVideoFormat === "webm" ? "libvpx-vp9" : "libx264",
  width = 1920,
  height = 1080,
  fps = 25,
  zoomInRatePerSecond = 0.02
}: {
  inputImageInBase64: string
  outputFilePath?: string
  outputDir?: string
  clearOutputDirAtTheEnd?: boolean
  outputVideoFormat?: string
  outputVideoDurationInMs?: number
  codec?: string
  width?: number
  height?: number
  fps?: number
  zoomInRatePerSecond?: number
}): Promise<string> {

  outputDir = outputDir || (await getRandomDirectory())

  outputDir = outputDir || await getRandomDirectory();

  // Decode the Base64 image and write it to a temporary file.
  const base64Data = inputImageInBase64.substring(inputImageInBase64.indexOf(',') + 1);
  const buffer = Buffer.from(base64Data, 'base64');
  const inputImagePath = path.join(outputDir, `${uuidv4()}.png`);
  await writeFile(inputImagePath, buffer);

  const inputImageDetails = await new Promise<ffmpeg.FfprobeData>((resolve, reject) => {
    ffmpeg.ffprobe(inputImagePath, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });

  const originalWidth = inputImageDetails.streams[0].width || width;
  const originalHeight = inputImageDetails.streams[0].height || height;
  const originalAspect = originalWidth / originalHeight;
  const targetAspect = width / height;
  let cropWidth, cropHeight;

  if (originalAspect > targetAspect) {
    // Crop width to match target aspect
    // console.log("imageToVideoBase64 case 1")
    cropHeight = originalHeight;
    cropWidth = Math.floor(cropHeight * targetAspect);
  } else {
    // Crop height to match target aspect
    // console.log("imageToVideoBase64 case 2")
    cropWidth = originalWidth;
    cropHeight = Math.floor(cropWidth / targetAspect);
  }

  // Set the path for the output video.
  outputFilePath = outputFilePath || path.join(outputDir, `output_${uuidv4()}.${outputVideoFormat}`);

  // we want to create a smooth Ken Burns effect
  const durationInSeconds = outputVideoDurationInMs / 1000;
  const framesTotal = durationInSeconds * fps;

  const xCenter = `iw/2-(iw/zoom/2)`;
  const yCenter = `ih/2-(ih/zoom/2)`;

  const startZoom = 1;
  const endZoom = 1 + zoomInRatePerSecond * durationInSeconds;
  
  const zoomDurationFrames = Math.ceil(durationInSeconds * fps); // Total frames for the video

  const videoFilters = [
    `crop=${cropWidth}:${cropHeight}:${(originalWidth - cropWidth) / 2}:${(originalHeight - cropHeight) / 2}`,
    `zoompan=z='min(zoom+${(endZoom - startZoom) / framesTotal}, ${endZoom})':x='${xCenter}':y='${yCenter}':d=${zoomDurationFrames}`
  ].join(',');

  /*
  console.log(`imagetoVideoBase64 called with: ${JSON.stringify({
    inputImageInBase64: inputImageInBase64?.slice(0, 50),
    outputFilePath,
    width,
    height,
    outputVideoDurationInMs,
    outputDir,
    clearOutputDirAtTheEnd,
    outputVideoFormat,
    originalWidth,
    originalHeight,
    originalAspect,
    targetAspect,
    cropHeight,
    cropWidth,
    durationInSeconds,
    framesTotal,
    xCenter,
    yCenter,
    startZoom,
    endZoom,
    zoomDurationFrames,
    videoFilters,
  }, null, 2)}`)
  */
 
  // Process the image to video conversion using ffmpeg.
  await new Promise<void>((resolve, reject) => {
    ffmpeg(inputImagePath)
      // this is disabled to avoid repeating the zoom-in multiple times
      // .inputOptions(['-loop 1'])
      .outputOptions([
        `-t ${durationInSeconds}`,
        `-r ${fps}`,
        `-s ${width}x${height}`,
        `-c:v ${codec}`,
        '-tune stillimage',
        '-pix_fmt yuv420p'
      ])
      .videoFilters(videoFilters)
      .on('start', commandLine => console.log('imageToVideoBase64: Spawned Ffmpeg with command: ' + commandLine))
      .on('end', () => resolve())
      .on('error', err => reject(err))
      .save(outputFilePath);
  });

  // Read the video file, encode it to Base64, and format it as a data URI.
  const videoBuffer = await readFile(outputFilePath);
  const videoBase64 = videoBuffer.toString('base64');
  const resultAsBase64DataUri = `data:video/${outputVideoFormat};base64,${videoBase64}`;

  // Attempt to clean up temporary work files.
  if (clearOutputDirAtTheEnd) {
    try {
      await rm(outputDir, { recursive: true, force: true });
    } catch (error) {
      console.error('Error removing temporary files:', error);
    }
  }
  
  return resultAsBase64DataUri;
}