|
1 | | -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; |
2 | | -import { tmpdir } from "node:os"; |
| 1 | +import { spawn } from "node:child_process"; |
| 2 | +import { readFileSync } from "node:fs"; |
3 | 3 | import { join } from "node:path"; |
4 | | -import ffmpeg from "fluent-ffmpeg"; |
| 4 | +import { getLogger } from "@logtape/logtape"; |
5 | 5 | import type { Sharp } from "sharp"; |
6 | 6 | import { drive } from "./storage"; |
7 | 7 |
|
| 8 | +const logger = getLogger(["hollo", "media"]); |
8 | 9 | const DEFAULT_THUMBNAIL_AREA = 230_400; |
| 10 | +const defaultScreenshot = readFileSync( |
| 11 | + join(import.meta.dirname, "..", "assets", "default-screenshot.png"), |
| 12 | +); |
9 | 13 |
|
10 | 14 | export interface Thumbnail { |
11 | 15 | thumbnailUrl: string; |
@@ -74,18 +78,59 @@ export function calculateThumbnailSize( |
74 | 78 | export async function makeVideoScreenshot( |
75 | 79 | videoData: Uint8Array, |
76 | 80 | ): Promise<Uint8Array> { |
77 | | - const tmpDir = await mkdtemp(join(tmpdir(), "hollo-")); |
78 | | - const inFile = join(tmpDir, "video"); |
79 | | - await writeFile(inFile, videoData); |
80 | | - await new Promise((resolve) => |
81 | | - ffmpeg(inFile) |
82 | | - .on("end", resolve) |
83 | | - .screenshots({ |
84 | | - timestamps: [0], |
85 | | - filename: "screenshot.png", |
86 | | - folder: tmpDir, |
87 | | - }), |
88 | | - ); |
89 | | - const screenshot = await readFile(join(tmpDir, "screenshot.png")); |
90 | | - return new Uint8Array(screenshot.buffer); |
| 81 | + const resultBuffer: Buffer = await new Promise((resolve, _) => { |
| 82 | + const process = spawn("ffmpeg", [ |
| 83 | + "-i", |
| 84 | + "pipe:0", |
| 85 | + "-vframes", |
| 86 | + "1", |
| 87 | + "-f", |
| 88 | + "image2pipe", |
| 89 | + "pipe:1", |
| 90 | + ]); |
| 91 | + const stdin = process.stdin; |
| 92 | + const stdout = process.stdout; |
| 93 | + const stderr = process.stderr; |
| 94 | + const chunks: Buffer[] = []; |
| 95 | + const stderrChunks: Buffer[] = []; |
| 96 | + if (!stdin || !stdout || !stderr) { |
| 97 | + logger.error( |
| 98 | + "Could not build pipes to ffmpeg, can't create a video screenshot", |
| 99 | + ); |
| 100 | + logger.error("ffmpeg output: {stderr}", { |
| 101 | + stderr: Buffer.concat(stderrChunks).toString(), |
| 102 | + }); |
| 103 | + resolve(defaultScreenshot); |
| 104 | + } |
| 105 | + stdout.on("data", (chunk) => { |
| 106 | + chunks.push(chunk); |
| 107 | + }); |
| 108 | + stderr.on("data", (chunk) => { |
| 109 | + stderrChunks.push(chunk); |
| 110 | + }); |
| 111 | + process.on("close", (code) => { |
| 112 | + if (code !== 0) { |
| 113 | + logger.error("ffmpeg returned a bad error code {code}", { code }); |
| 114 | + logger.error("ffmpeg output: {stderr}", { |
| 115 | + stderr: Buffer.concat(stderrChunks).toString(), |
| 116 | + }); |
| 117 | + resolve(defaultScreenshot); |
| 118 | + } |
| 119 | + resolve(Buffer.concat(chunks)); |
| 120 | + }); |
| 121 | + process.on("error", (error) => { |
| 122 | + logger.error("Could not run ffmpeg: {error}", { error }); |
| 123 | + logger.error("ffmpeg output: {stderr}", { |
| 124 | + stderr: Buffer.concat(stderrChunks).toString(), |
| 125 | + }); |
| 126 | + resolve(defaultScreenshot); |
| 127 | + }); |
| 128 | + stdin.on("error", (_) => { |
| 129 | + // probably a EPIPE because ffmpeg does not consume the whole file; swallow it here |
| 130 | + }); |
| 131 | + |
| 132 | + stdin.write(videoData); |
| 133 | + stdin.end(); |
| 134 | + }); |
| 135 | + return resultBuffer; |
91 | 136 | } |
0 commit comments