mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 22:18:57 +00:00
✨ fix: reassemble JPEG frame boundaries and report ffmpeg progress
This commit is contained in:
+103
-14
@@ -1,40 +1,129 @@
|
|||||||
import ffmpeg from "fluent-ffmpeg";
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { FramesContainer } from "./frames";
|
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<void> {
|
||||||
|
const maxDimension = options.maxDimension ?? 320;
|
||||||
|
|
||||||
export async function process(path: string, output: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ffmpeg(path).ffprobe((err, data) => {
|
ffmpeg(path).ffprobe((err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(new Error("An error occurred: " + err.message));
|
reject(new Error("ffprobe failed: " + err.message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.streams[0].r_frame_rate) {
|
const stream = data.streams?.[0];
|
||||||
reject(new Error("Unable to get video fps"));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoData: FramesContainer = {
|
const nbFrames = stream?.nb_frames
|
||||||
frames: [],
|
? parseInt(String(stream.nb_frames), 10)
|
||||||
fps: parseFloat(data.streams[0].r_frame_rate),
|
: 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)
|
const ffvideo = ffmpeg(path)
|
||||||
.outputOptions("-f image2pipe")
|
.outputOptions("-c:v", "mjpeg")
|
||||||
.outputOptions("-vf format=gray")
|
.outputOptions("-q:v", "3")
|
||||||
.on("error", (err) => {
|
.outputOptions(
|
||||||
console.log("An error occurred: " + err.message);
|
"-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();
|
const ffstream = ffvideo.pipe();
|
||||||
|
ffstream.on("error", (err: Error) =>
|
||||||
|
fail("ffmpeg stream error: " + err.message)
|
||||||
|
);
|
||||||
ffstream.on("data", (chunk: Buffer) => {
|
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", () => {
|
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));
|
fs.writeFileSync(output, JSON.stringify(videoData));
|
||||||
console.log("Frames saved to", output);
|
logger.info(
|
||||||
|
`Saved ${videoData.frames.length} frames to ${output}`
|
||||||
|
);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user