From 927a953d439c6df35a44ec768a47daacb9f0506f Mon Sep 17 00:00:00 2001 From: Yuzu Date: Mon, 13 Jul 2026 16:59:48 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20fix:=20reassemble=20JPEG=20frame=20?= =?UTF-8?q?boundaries=20and=20report=20ffmpeg=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/videoProcessor.ts | 117 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 14 deletions(-) diff --git a/src/videoProcessor.ts b/src/videoProcessor.ts index a6f4d52..fa776f1 100644 --- a/src/videoProcessor.ts +++ b/src/videoProcessor.ts @@ -1,40 +1,129 @@ import ffmpeg from "fluent-ffmpeg"; import fs from "fs"; import { FramesContainer } from "./frames"; +import { logger } from "./logger"; + +const SOI = Buffer.from([0xff, 0xd8]); +const EOI = Buffer.from([0xff, 0xd9]); + +export interface ProcessOptions { + maxDimension?: number; +} + +class JpegFrameSplitter { + private buffer = Buffer.alloc(0); + + push(chunk: Buffer): Buffer[] { + this.buffer = Buffer.concat([this.buffer, chunk]); + const frames: Buffer[] = []; + + for (;;) { + const start = this.buffer.indexOf(SOI); + if (start === -1) break; + const end = this.buffer.indexOf(EOI, start + SOI.length); + if (end === -1) break; + + const frameEnd = end + EOI.length; + frames.push(this.buffer.subarray(start, frameEnd)); + this.buffer = this.buffer.subarray(frameEnd); + } + + return frames; + } +} + +export async function process( + path: string, + output: string, + options: ProcessOptions = {} +): Promise { + const maxDimension = options.maxDimension ?? 320; -export async function process(path: string, output: string): Promise { return new Promise((resolve, reject) => { ffmpeg(path).ffprobe((err, data) => { if (err) { - reject(new Error("An error occurred: " + err.message)); + reject(new Error("ffprobe failed: " + err.message)); return; } - if (!data.streams[0].r_frame_rate) { - reject(new Error("Unable to get video fps")); + const stream = data.streams?.[0]; + const rate = stream?.r_frame_rate; + const fps = rate ? parseFloat(rate) : NaN; + if (!Number.isFinite(fps) || fps <= 0) { + reject(new Error("Unable to determine a valid video fps")); return; } - const videoData: FramesContainer = { - frames: [], - fps: parseFloat(data.streams[0].r_frame_rate), + const nbFrames = stream?.nb_frames + ? parseInt(String(stream.nb_frames), 10) + : NaN; + const duration = Number(data.format?.duration ?? stream?.duration); + const totalFrames = Number.isFinite(nbFrames) + ? nbFrames + : Number.isFinite(duration) + ? Math.round(duration * fps) + : undefined; + + const videoData: FramesContainer = { frames: [], fps }; + const splitter = new JpegFrameSplitter(); + + let settled = false; + const fail = (message: string) => { + if (settled) return; + settled = true; + reject(new Error(message)); + }; + + const reportProgress = (count: number) => { + if (totalFrames && totalFrames > 0) { + const pct = Math.min( + 100, + Math.round((count / totalFrames) * 100) + ); + globalThis.process.stdout.write( + `\rGenerating frames: ${count}/${totalFrames} (${pct}%)` + ); + } else { + globalThis.process.stdout.write( + `\rGenerating frames: ${count}` + ); + } }; const ffvideo = ffmpeg(path) - .outputOptions("-f image2pipe") - .outputOptions("-vf format=gray") - .on("error", (err) => { - console.log("An error occurred: " + err.message); - }); + .outputOptions("-c:v", "mjpeg") + .outputOptions("-q:v", "3") + .outputOptions( + "-vf", + `format=gray,scale=w=${maxDimension}:h=${maxDimension}:` + + `force_original_aspect_ratio=decrease` + ) + .outputOptions("-f", "image2pipe") + .on("error", (err) => fail("ffmpeg failed: " + err.message)); const ffstream = ffvideo.pipe(); + ffstream.on("error", (err: Error) => + fail("ffmpeg stream error: " + err.message) + ); ffstream.on("data", (chunk: Buffer) => { - videoData.frames.push(chunk); + for (const frame of splitter.push(chunk)) { + videoData.frames.push(frame); + } + reportProgress(videoData.frames.length); }); ffstream.on("end", () => { + if (settled) return; + if (videoData.frames.length === 0) { + fail("No frames were decoded from the video"); + return; + } + settled = true; + globalThis.process.stdout.write("\n"); fs.writeFileSync(output, JSON.stringify(videoData)); - console.log("Frames saved to", output); + logger.info( + `Saved ${videoData.frames.length} frames to ${output}` + ); resolve(); }); });