diff --git a/Dockerfile b/Dockerfile index b1648045..a2da2191 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/node:24.0-alpine +FROM docker.io/node:24-alpine LABEL org.opencontainers.image.title="Hollo" LABEL org.opencontainers.image.description="Federated single-user \ diff --git a/assets/default-screenshot.png b/assets/default-screenshot.png new file mode 100644 index 00000000..99b99549 Binary files /dev/null and b/assets/default-screenshot.png differ diff --git a/package.json b/package.json index b9c52b35..1018f3d5 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.0", "es-toolkit": "^1.44.0", - "fluent-ffmpeg": "^2.1.3", "flydrive": "^1.3.0", "hono": "^4.11.4", "iso-639-1": "^3.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a29a6a9a..676ba10a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: es-toolkit: specifier: ^1.44.0 version: 1.44.0 - fluent-ffmpeg: - specifier: ^2.1.3 - version: 2.1.3 flydrive: specifier: ^1.3.0 version: 1.3.0(@aws-sdk/client-s3@3.713.0)(@aws-sdk/s3-request-presigner@3.713.0) @@ -2771,9 +2768,6 @@ packages: engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true - async@0.2.10: - resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -3356,11 +3350,6 @@ packages: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} - fluent-ffmpeg@2.1.3: - resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} - engines: {node: '>=18'} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - flydrive@1.3.0: resolution: {integrity: sha512-B0wsqrZR76d+J2ce6AxNcA1JDo4pViluaaFp5Fjso52zGY+s19uF8rKsQS8TJJsiyySKPI1b8K1w2j3RAUxd1Q==} engines: {node: '>=20.6.0'} @@ -4982,10 +4971,6 @@ packages: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -8525,8 +8510,6 @@ snapshots: - uploadthing - yaml - async@0.2.10: {} - axobject-query@4.1.0: {} bail@2.0.2: {} @@ -9105,11 +9088,6 @@ snapshots: flattie@1.1.1: {} - fluent-ffmpeg@2.1.3: - dependencies: - async: 0.2.10 - which: 1.3.1 - flydrive@1.3.0(@aws-sdk/client-s3@3.713.0)(@aws-sdk/s3-request-presigner@3.713.0): dependencies: '@humanwhocodes/retry': 0.4.3 @@ -11172,10 +11150,6 @@ snapshots: which-pm-runs@1.1.0: {} - which@1.3.1: - dependencies: - isexe: 2.0.0 - which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/src/media.ts b/src/media.ts index 793c5550..51b19484 100644 --- a/src/media.ts +++ b/src/media.ts @@ -1,11 +1,15 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; -import ffmpeg from "fluent-ffmpeg"; +import { getLogger } from "@logtape/logtape"; import type { Sharp } from "sharp"; import { drive } from "./storage"; +const logger = getLogger(["hollo", "media"]); const DEFAULT_THUMBNAIL_AREA = 230_400; +const defaultScreenshot = readFileSync( + join(import.meta.dirname, "..", "assets", "default-screenshot.png"), +); export interface Thumbnail { thumbnailUrl: string; @@ -74,18 +78,59 @@ export function calculateThumbnailSize( export async function makeVideoScreenshot( videoData: Uint8Array, ): Promise { - const tmpDir = await mkdtemp(join(tmpdir(), "hollo-")); - const inFile = join(tmpDir, "video"); - await writeFile(inFile, videoData); - await new Promise((resolve) => - ffmpeg(inFile) - .on("end", resolve) - .screenshots({ - timestamps: [0], - filename: "screenshot.png", - folder: tmpDir, - }), - ); - const screenshot = await readFile(join(tmpDir, "screenshot.png")); - return new Uint8Array(screenshot.buffer); + const resultBuffer: Buffer = await new Promise((resolve, _) => { + const process = spawn("ffmpeg", [ + "-i", + "pipe:0", + "-vframes", + "1", + "-f", + "image2pipe", + "pipe:1", + ]); + const stdin = process.stdin; + const stdout = process.stdout; + const stderr = process.stderr; + const chunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + if (!stdin || !stdout || !stderr) { + logger.error( + "Could not build pipes to ffmpeg, can't create a video screenshot", + ); + logger.error("ffmpeg output: {stderr}", { + stderr: Buffer.concat(stderrChunks).toString(), + }); + resolve(defaultScreenshot); + } + stdout.on("data", (chunk) => { + chunks.push(chunk); + }); + stderr.on("data", (chunk) => { + stderrChunks.push(chunk); + }); + process.on("close", (code) => { + if (code !== 0) { + logger.error("ffmpeg returned a bad error code {code}", { code }); + logger.error("ffmpeg output: {stderr}", { + stderr: Buffer.concat(stderrChunks).toString(), + }); + resolve(defaultScreenshot); + } + resolve(Buffer.concat(chunks)); + }); + process.on("error", (error) => { + logger.error("Could not run ffmpeg: {error}", { error }); + logger.error("ffmpeg output: {stderr}", { + stderr: Buffer.concat(stderrChunks).toString(), + }); + resolve(defaultScreenshot); + }); + stdin.on("error", (_) => { + // probably a EPIPE because ffmpeg does not consume the whole file; swallow it here + }); + + stdin.write(videoData); + stdin.end(); + }); + return resultBuffer; }