jbilcke-hf HF staff commited on
Commit
46fcec6
·
1 Parent(s): 1fc1c4d

another step for the Stories Factory (mp4 generation)

Browse files
src/core/base64/extractBase64.mts CHANGED
@@ -2,8 +2,13 @@
2
  * break a base64 string into sub-components
3
  */
4
  export function extractBase64(base64: string = ""): {
 
 
5
  mimetype: string;
 
 
6
  extension: string;
 
7
  data: string;
8
  buffer: Buffer;
9
  blob: Blob;
 
2
  * break a base64 string into sub-components
3
  */
4
  export function extractBase64(base64: string = ""): {
5
+
6
+ // file format eg. video/mp4 text/html audio/wave
7
  mimetype: string;
8
+
9
+ // file extension eg. .mp4 .html .wav
10
  extension: string;
11
+
12
  data: string;
13
  buffer: Buffer;
14
  blob: Blob;
src/core/converters/htmlToBase64Png.mts CHANGED
@@ -28,7 +28,7 @@ export async function htmlToBase64Png({
28
  }
29
 
30
  const browser = await puppeteer.launch({
31
- headless: "new",
32
 
33
  // apparently we need those, see:
34
  // https://unix.stackexchange.com/questions/694734/puppeteer-in-alpine-docker-with-chromium-headless-dosent-seems-to-work
 
28
  }
29
 
30
  const browser = await puppeteer.launch({
31
+ headless: true,
32
 
33
  // apparently we need those, see:
34
  // https://unix.stackexchange.com/questions/694734/puppeteer-in-alpine-docker-with-chromium-headless-dosent-seems-to-work
src/core/ffmpeg/addTextToVideo.mts CHANGED
@@ -1,23 +1,37 @@
1
- import { createTextOverlayImage } from "./createTextOverlayImage.mts";
2
- import { addImageToVideo } from "./addImageToVideo.mts";
 
3
 
4
- export async function addTextToVideo() {
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- const inputVideoPath = "/Users/jbilcke/Downloads/use_me.mp4"
7
-
8
- const { filePath } = await createTextOverlayImage({
9
- text: "This tech is hot 🥵",
10
- width: 1024 ,
11
- height: 576,
12
  })
13
- console.log("filePath:", filePath)
14
 
15
- /*
 
16
  const pathToVideo = await addImageToVideo({
17
  inputVideoPath,
18
- inputImagePath: filePath,
 
19
  })
20
 
21
- console.log("pathToVideo:", pathToVideo)
22
- */
 
 
23
  }
 
1
+ import { createTextOverlayImage } from "./createTextOverlayImage.mts"
2
+ import { addImageToVideo } from "./addImageToVideo.mts"
3
+ import { deleteFile } from "../files/deleteFile.mts"
4
 
5
+ export async function addTextToVideo({
6
+ inputVideoPath,
7
+ outputVideoPath,
8
+ text,
9
+ width,
10
+ height,
11
+ }: {
12
+ inputVideoPath: string
13
+ outputVideoPath: string
14
+ text: string
15
+ width: number
16
+ height: number
17
+ }): Promise<string> {
18
 
19
+ const { filePath: temporaryImageOverlayFilePath } = await createTextOverlayImage({
20
+ text,
21
+ width,
22
+ height,
 
 
23
  })
 
24
 
25
+ console.log("addTextToVideo: temporaryImageOverlayFilePath:", temporaryImageOverlayFilePath)
26
+
27
  const pathToVideo = await addImageToVideo({
28
  inputVideoPath,
29
+ inputImagePath: temporaryImageOverlayFilePath,
30
+ outputVideoPath,
31
  })
32
 
33
+ await deleteFile(temporaryImageOverlayFilePath)
34
+
35
+ console.log("addTextToVideo: outputVideoPath:", outputVideoPath)
36
+ return outputVideoPath
37
  }
src/core/files/deleteFile.mts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { unlink, rm } from "node:fs/promises"
2
+
3
+ export async function deleteFile(filePath: string, debug?: boolean): Promise<boolean> {
4
+ try {
5
+ await rm(filePath, { recursive: true, force: true })
6
+ // await unlink(filePath)
7
+ return true
8
+ } catch (err) {
9
+ if (debug) {
10
+ console.error(`failed to unlink file at ${filePath}: ${err}`)
11
+ }
12
+ }
13
+ return false
14
+ }
src/core/files/deleteFileWithName.mts CHANGED
@@ -1,17 +1,11 @@
1
  import { promises as fs } from "node:fs"
2
  import path from "node:path"
 
3
 
4
  export const deleteFilesWithName = async (dir: string, name: string, debug?: boolean) => {
5
  for (const file of await fs.readdir(dir)) {
6
  if (file.includes(name)) {
7
- const filePath = path.join(dir, file)
8
- try {
9
- await fs.unlink(filePath)
10
- } catch (err) {
11
- if (debug) {
12
- console.error(`failed to unlink file in ${filePath}: ${err}`)
13
- }
14
- }
15
  }
16
  }
17
  }
 
1
  import { promises as fs } from "node:fs"
2
  import path from "node:path"
3
+ import { deleteFile } from "./deleteFile.mts"
4
 
5
  export const deleteFilesWithName = async (dir: string, name: string, debug?: boolean) => {
6
  for (const file of await fs.readdir(dir)) {
7
  if (file.includes(name)) {
8
+ await deleteFile(path.join(dir, file))
 
 
 
 
 
 
 
9
  }
10
  }
11
  }
src/core/files/getRandomDirectory.mts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { tmpdir } from "node:os"
2
+ import { join } from "node:path"
3
+ import { mkdtemp } from "node:fs/promises"
4
+ import { v4 as uuidv4 } from "uuid"
5
+
6
+ export async function getRandomDirectory(): Promise<string> {
7
+ return mkdtemp(join(tmpdir(), uuidv4()))
8
+ }
src/core/files/removeTmpFiles.mts CHANGED
@@ -1,22 +1,18 @@
1
  import { existsSync, promises as fs } from "node:fs"
2
 
3
- import { keepTemporaryFiles } from "../config.mts"
4
-
5
  // note: this function will never fail
6
  export async function removeTemporaryFiles(filesPaths: string[]) {
7
  try {
8
- if (!keepTemporaryFiles) {
9
- // Cleanup temporary files - you could choose to do this or leave it to the user
10
- await Promise.all(filesPaths.map(async (filePath) => {
11
- try {
12
- if (existsSync(filePath)) {
13
- await fs.unlink(filePath)
14
- }
15
- } catch (err) {
16
- //
17
  }
18
- }))
19
- }
 
 
20
  } catch (err) {
21
  // no big deal, except a bit of tmp file leak
22
  // although.. if delete failed, it could also indicate
 
1
  import { existsSync, promises as fs } from "node:fs"
2
 
 
 
3
  // note: this function will never fail
4
  export async function removeTemporaryFiles(filesPaths: string[]) {
5
  try {
6
+ // Cleanup temporary files - you could choose to do this or leave it to the user
7
+ await Promise.all(filesPaths.map(async (filePath) => {
8
+ try {
9
+ if (existsSync(filePath)) {
10
+ await fs.rm(filePath)
 
 
 
 
11
  }
12
+ } catch (err) {
13
+ //
14
+ }
15
+ }))
16
  } catch (err) {
17
  // no big deal, except a bit of tmp file leak
18
  // although.. if delete failed, it could also indicate
src/core/utils/startOfSegment1IsWithinSegment2.mts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { ClapSegment } from "../clap/types.mts";
2
+
3
+ export function startOfSegment1IsWithinSegment2(s1: ClapSegment, s2: ClapSegment) {
4
+ const startOfSegment1 = s1.startTimeInMs
5
+ return s2.startTimeInMs <= startOfSegment1 && startOfSegment1 <= s2.endTimeInMs
6
+ }
src/index.mts CHANGED
@@ -4,6 +4,8 @@ import { Blob } from "buffer"
4
 
5
  import { parseClap } from "./core/clap/parseClap.mts"
6
  import { ClapProject } from "./core/clap/types.mts"
 
 
7
 
8
  const app = express()
9
  const port = 7860
@@ -49,10 +51,23 @@ app.post("/", async (req, res) => {
49
  req.on("end", async () => {
50
  let clapProject: ClapProject
51
  try {
52
- let fileData = Buffer.concat(data);
53
- const clapBlob = new Blob([fileData]);
54
- clapProject = await parseClap(clapBlob);
55
- console.log("got a clap project!:", clapProject)
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  } catch (err) {
57
  console.error(`failed to parse the request: ${err}`)
58
  res.status(500)
@@ -60,10 +75,6 @@ app.post("/", async (req, res) => {
60
  res.end()
61
  return
62
  }
63
- // TODO read the mp4 file and convert it to
64
- res.status(200)
65
- res.write("TODO")
66
- res.end()
67
  });
68
  })
69
 
 
4
 
5
  import { parseClap } from "./core/clap/parseClap.mts"
6
  import { ClapProject } from "./core/clap/types.mts"
7
+ import { clapToTmpVideoFilePath } from "./main.mts"
8
+ import { deleteFile } from "./core/files/deleteFile.mts"
9
 
10
  const app = express()
11
  const port = 7860
 
51
  req.on("end", async () => {
52
  let clapProject: ClapProject
53
  try {
54
+ let fileData = Buffer.concat(data)
55
+
56
+ const clap = await parseClap(new Blob([fileData]));
57
+ console.log("got a clap project:", clapProject)
58
+
59
+ const {
60
+ tmpWorkDir,
61
+ outputFilePath,
62
+ } = await clapToTmpVideoFilePath({ clap })
63
+ console.log("got an output file at:", outputFilePath)
64
+
65
+ res.download(outputFilePath, async () => {
66
+ // clean-up after ourselves (we clear the whole tmp directory)
67
+ await deleteFile(tmpWorkDir)
68
+ console.log("cleared the temporary folder")
69
+ })
70
+ return
71
  } catch (err) {
72
  console.error(`failed to parse the request: ${err}`)
73
  res.status(500)
 
75
  res.end()
76
  return
77
  }
 
 
 
 
78
  });
79
  })
80
 
src/main.mts CHANGED
@@ -1,7 +1,4 @@
1
- import { tmpdir } from "node:os"
2
  import { join } from "node:path"
3
- import { mkdtemp } from "node:fs/promises"
4
- import { v4 as uuidv4 } from "uuid"
5
 
6
  import { ClapProject } from "./core/clap/types.mts";
7
  import { concatenateAudio } from "./core/ffmpeg/concatenateAudio.mts";
@@ -9,6 +6,11 @@ import { concatenateVideosWithAudio } from "./core/ffmpeg/concatenateVideosWithA
9
  import { writeBase64ToFile } from "./core/files/writeBase64ToFile.mts";
10
  import { concatenateVideos } from "./core/ffmpeg/concatenateVideos.mts"
11
  import { deleteFilesWithName } from "./core/files/deleteFileWithName.mts"
 
 
 
 
 
12
 
13
  /**
14
  * Generate a .mp4 video inside a direcory (if none is provided, it will be created in /tmp)
@@ -16,56 +18,139 @@ import { deleteFilesWithName } from "./core/files/deleteFileWithName.mts"
16
  * @param clap
17
  * @returns file path to the final .mp4
18
  */
19
- export async function clapToTmpVideoFilePath(clap: ClapProject, dir = ""): Promise<string> {
 
 
 
 
 
20
 
21
- dir = dir || (await mkdtemp(join(tmpdir(), uuidv4())))
 
 
 
 
 
 
 
 
 
 
22
 
23
  const videoFilePaths: string[] = []
24
  const videoSegments = clap.segments.filter(s => s.category === "video" && s.assetUrl.startsWith("data:video/"))
25
 
26
  for (const segment of videoSegments) {
27
- videoFilePaths.push(
28
- await writeBase64ToFile(
29
- segment.assetUrl,
30
- join(dir, `tmp_asset_${segment.id}.mp4`)
31
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
 
35
- const concatenatedVideosNoSound = await concatenateVideos({
36
  videoFilePaths,
37
- output: join(dir, `tmp_asset_concatenated_videos.mp4`)
38
  })
39
 
40
  const audioTracks: string[] = []
41
 
42
- const musicSegments = clap.segments.filter(s => s.category === "music" && s.assetUrl.startsWith("data:audio/"))
 
 
 
43
  for (const segment of musicSegments) {
44
  audioTracks.push(
45
  await writeBase64ToFile(
46
  segment.assetUrl,
47
- join(dir, `tmp_asset_${segment.id}.wav`)
48
  )
49
  )
50
  }
51
 
52
  const concatenatedAudio = await concatenateAudio({
53
- output: join(dir, `tmp_asset_concatenated_audio.wav`),
54
  audioTracks,
55
  crossfadeDurationInSec: 2 // 2 seconds
56
  })
57
 
58
- const finalFilePathOfVideoWithSound = await concatenateVideosWithAudio({
59
- output: join(dir, `final_video.mp4`),
60
  audioFilePath: concatenatedAudio.filepath,
61
- videoFilePaths: [concatenatedVideosNoSound.filepath],
62
  // videos are silent, so they can stay at 0
63
- videoTracksVolume: 0.0,
64
- audioTrackVolume: 1.0,
65
  })
66
 
67
- // we delete all the temporary assets
68
- await deleteFilesWithName(dir, `tmp_asset_`)
 
 
69
 
70
- return finalFilePathOfVideoWithSound
 
 
 
71
  }
 
 
1
  import { join } from "node:path"
 
 
2
 
3
  import { ClapProject } from "./core/clap/types.mts";
4
  import { concatenateAudio } from "./core/ffmpeg/concatenateAudio.mts";
 
6
  import { writeBase64ToFile } from "./core/files/writeBase64ToFile.mts";
7
  import { concatenateVideos } from "./core/ffmpeg/concatenateVideos.mts"
8
  import { deleteFilesWithName } from "./core/files/deleteFileWithName.mts"
9
+ import { getRandomDirectory } from "./core/files/getRandomDirectory.mts";
10
+ import { addTextToVideo } from "./core/ffmpeg/addTextToVideo.mts";
11
+ import { startOfSegment1IsWithinSegment2 } from "./core/utils/startOfSegment1IsWithinSegment2.mts";
12
+ import { deleteFile } from "./core/files/deleteFile.mts";
13
+ import { extractBase64 } from "./core/base64/extractBase64.mts";
14
 
15
  /**
16
  * Generate a .mp4 video inside a direcory (if none is provided, it will be created in /tmp)
 
18
  * @param clap
19
  * @returns file path to the final .mp4
20
  */
21
+ export async function clapToTmpVideoFilePath({
22
+ clap,
23
+ outputDir = "",
24
+ clearTmpFilesAtEnd = false
25
+ }: {
26
+ clap: ClapProject
27
 
28
+ outputDir?: string
29
+
30
+ // if you leave this to false, you will have to clear files yourself
31
+ // (eg. after sending the final video file over)
32
+ clearTmpFilesAtEnd?: boolean
33
+ }): Promise<{
34
+ tmpWorkDir: string
35
+ outputFilePath: string
36
+ }> {
37
+
38
+ outputDir = outputDir || (await getRandomDirectory())
39
 
40
  const videoFilePaths: string[] = []
41
  const videoSegments = clap.segments.filter(s => s.category === "video" && s.assetUrl.startsWith("data:video/"))
42
 
43
  for (const segment of videoSegments) {
44
+
45
+ const base64Info = extractBase64(segment.assetUrl)
46
+
47
+ // we write it to the disk *unconverted* (it might be a mp4, a webm or something else)
48
+ let videoSegmentFilePath = await writeBase64ToFile(
49
+ segment.assetUrl,
50
+ join(outputDir, `tmp_asset_${segment.id}.${base64Info.extension}`)
51
+ )
52
+
53
+ const interfaceSegments = clap.segments.filter(s =>
54
+ s.assetUrl.startsWith("data:text/") &&
55
+ s.category === "interface" &&
56
+ startOfSegment1IsWithinSegment2(s, segment)
57
+ )
58
+ const interfaceSegment = interfaceSegments.at(0)
59
+ if (interfaceSegment) {
60
+ // here we are free to use mp4, since this is an internal intermediary format
61
+ const videoSegmentWithOverlayFilePath = join(outputDir, `tmp_asset_${segment.id}_with_interface.mp4`)
62
+
63
+ await addTextToVideo({
64
+ inputVideoPath: videoSegmentFilePath,
65
+ outputVideoPath: videoSegmentWithOverlayFilePath,
66
+ text: atob(extractBase64(interfaceSegment.assetUrl).data),
67
+ width: clap.meta.width,
68
+ height: clap.meta.height,
69
+ })
70
+
71
+ // we overwrite
72
+ await deleteFile(videoSegmentFilePath)
73
+ videoSegmentFilePath = videoSegmentWithOverlayFilePath
74
+ }
75
+
76
+ const dialogueSegments = clap.segments.filter(s =>
77
+ s.assetUrl.startsWith("data:audio/") &&
78
+ s.category === "dialogue" &&
79
+ startOfSegment1IsWithinSegment2(s, segment)
80
  )
81
+ const dialogueSegment = dialogueSegments.at(0)
82
+ if (dialogueSegment) {
83
+ extractBase64(dialogueSegment.assetUrl)
84
+ const base64Info = extractBase64(segment.assetUrl)
85
+
86
+ const dialogueSegmentFilePath = await writeBase64ToFile(
87
+ dialogueSegment.assetUrl,
88
+ join(outputDir, `tmp_asset_${segment.id}_dialogue.${base64Info.extension}`)
89
+ )
90
+
91
+ const finalFilePathOfVideoWithSound = await concatenateVideosWithAudio({
92
+ output: join(outputDir, `${segment.id}_video_with_audio.mp4`),
93
+ audioFilePath: dialogueSegmentFilePath,
94
+ videoFilePaths: [videoSegmentFilePath],
95
+ // videos are silent, so they can stay at 0
96
+ videoTracksVolume: 0.0,
97
+ audioTrackVolume: 1.0,
98
+ })
99
+
100
+ // we delete the temporary dialogue file
101
+ await deleteFile(dialogueSegmentFilePath)
102
+
103
+ // we overwrite the video segment
104
+ await deleteFile(videoSegmentFilePath)
105
+
106
+ videoSegmentFilePath = finalFilePathOfVideoWithSound
107
+ }
108
+
109
+ videoFilePaths.push(videoSegmentFilePath)
110
  }
111
 
112
+ const concatenatedVideosNoMusic = await concatenateVideos({
113
  videoFilePaths,
114
+ output: join(outputDir, `tmp_asset_concatenated_videos.mp4`)
115
  })
116
 
117
  const audioTracks: string[] = []
118
 
119
+ const musicSegments = clap.segments.filter(s =>
120
+ s.category === "music" &&
121
+ s.assetUrl.startsWith("data:audio/")
122
+ )
123
  for (const segment of musicSegments) {
124
  audioTracks.push(
125
  await writeBase64ToFile(
126
  segment.assetUrl,
127
+ join(outputDir, `tmp_asset_${segment.id}.wav`)
128
  )
129
  )
130
  }
131
 
132
  const concatenatedAudio = await concatenateAudio({
133
+ output: join(outputDir, `tmp_asset_concatenated_audio.wav`),
134
  audioTracks,
135
  crossfadeDurationInSec: 2 // 2 seconds
136
  })
137
 
138
+ const finalFilePathOfVideoWithMusic = await concatenateVideosWithAudio({
139
+ output: join(outputDir, `final_video.mp4`),
140
  audioFilePath: concatenatedAudio.filepath,
141
+ videoFilePaths: [concatenatedVideosNoMusic.filepath],
142
  // videos are silent, so they can stay at 0
143
+ videoTracksVolume: 0.85,
144
+ audioTrackVolume: 0.15, // let's keep the music volume low
145
  })
146
 
147
+ if (clearTmpFilesAtEnd) {
148
+ // we delete all the temporary assets
149
+ await deleteFilesWithName(outputDir, `tmp_asset_`)
150
+ }
151
 
152
+ return {
153
+ tmpWorkDir: outputDir,
154
+ outputFilePath: finalFilePathOfVideoWithMusic
155
+ }
156
  }