mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
feat: batch video upscaling with folder upload, directory save, and result comparison (#1051)
* feat: support batch video upscaling with folder upload and directory save - Add multi-file selection and entire folder upload (webkitdirectory) - Support drag & drop of both files and folders via FileSystemEntry API - Add save directory picker using File System Access API (showDirectoryPicker) - Process videos sequentially with per-file progress and status tracking - Add right-side result list with click-to-compare functionality - Add per-video duration and total batch duration display - Add retry button for failed items with error detail display - Remove auto-play on comparison viewer load - Fix worker error handling: handle "error" command to reject stale promises - Fix event listener accumulation in video comparison (AbortController) - Fix XSS risk by escaping HTML in all innerHTML injections - Fix URL revocation timing in download to prevent large file truncation - Extract writeToSaveDirectory and truncateName helpers to reduce duplication - Add Usage Guide panel explaining single/multiple/folder upload options Files changed: docs/upscaler/index.html (+1110/-181) docs/upscaler/upscaler.js (+110) * fix: prevent auto-play and resource consumption during upscaling - Set preload="metadata" on preview and comparison video elements - Explicitly pause preview video after setting src in showVideoPreview - Remove preview video seek sync from batch processing progress callbacks (was causing continuous decode during upscaling, wasting GPU resources) - Remove preview video seek sync from retry progress callback - All videos now require manual play; no background resource usage * fix: improve upscaled video quality with higher bitrate and codec support - Raise base bitrate from 2.5 Mbps (25e5) to 8 Mbps (8e6) for standard and firefox processors, preventing quality loss when source videos have high bitrates (e.g. 720p @ 16.7 Mbps was producing 1440p @ 10 Mbps, smaller than the original file) - Double the bitrate for mediabunny processor (X.n1 * 2) to account for the 4x pixel increase from 2x upscaling - Add H.264 Level 5.1 (avc1.640033) and Level 5.2 (avc1.640034) codecs to the fallback lists, since Level 4.0 (avc1.640028, ~24.6 Mbps) cannot support the higher bitrates for 1440p+ outputs - Add latencyMode: "quality" to VideoEncoder configs for both standard and firefox processors, prioritizing encoding quality over latency (suitable for offline processing) * feat: add video data comparison panel for original vs upscaled metrics - Add a comparison data table below the file info bar showing 6 metrics: Resolution, File Size, Duration, Est. Bitrate, Pixels, Process Time - Display per-metric change percentage with color-coded indicators: green (↑) for improvements, red (↓) for regressions, gray for neutral - Estimated bitrate calculated from file size × 8 / video duration - Table renders after both original and upscaled video metadata loads - Auto-hide panel on reset * fix: make video data comparison panel full-width and responsive - Move comparison data table from inside the left column to the full-width area below the left/right layout, so it is no longer constrained by the comparison viewer column width - Remove table-fixed colgroup and overflow-x-auto that caused horizontal scrolling on smaller screens - Table now uses w-full auto-layout, displaying all 4 columns (Metric, Original, Upscaled, Change) without requiring horizontal scrolling --------- Co-authored-by: rionzhao <rionzhao@tencent.com>
This commit is contained in:
1 parent
bb82f899ff
commit
40fcda6a37
3 files changed
+1290
-203
No files matched your search
+1172
-197
File diff suppressed because it is too large.
Load diff
+109
-1
@@ -69,6 +69,8 @@ class Upscaler {
|
||||
this.messageHandlers = {};
|
||||
this.progressCallback = null;
|
||||
this.processingType = null;
|
||||
this.currentReject = null;
|
||||
this.currentTimeoutId = null;
|
||||
|
||||
this.realtimeLoopId = null;
|
||||
this.realtimeState = null;
|
||||
@@ -91,10 +93,39 @@ class Upscaler {
|
||||
this.workerInstance = new Worker(this.workerUrl);
|
||||
this.workerInstance.onmessage = (event) =>
|
||||
this.handleWorkerMessage(event);
|
||||
this.workerInstance.onerror = (error) => {
|
||||
console.error("[Upscaler] Worker error:", error);
|
||||
this.rejectCurrent(
|
||||
new Error(
|
||||
error.message || "Worker crashed unexpectedly",
|
||||
),
|
||||
);
|
||||
};
|
||||
this.workerInstance.onmessageerror = (error) => {
|
||||
console.error("[Upscaler] Worker message error:", error);
|
||||
this.rejectCurrent(
|
||||
new Error("Worker message serialization error"),
|
||||
);
|
||||
};
|
||||
}
|
||||
return this.workerInstance;
|
||||
}
|
||||
|
||||
rejectCurrent(error) {
|
||||
if (this.currentReject) {
|
||||
const reject = this.currentReject;
|
||||
this.currentReject = null;
|
||||
if (this.currentTimeoutId) {
|
||||
clearTimeout(this.currentTimeoutId);
|
||||
this.currentTimeoutId = null;
|
||||
}
|
||||
// Clean up whichever blob handler is currently registered
|
||||
delete this.messageHandlers.videoBlob;
|
||||
delete this.messageHandlers.imageBlob;
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
static extractProgressValue(data) {
|
||||
const value = data.data ?? data.progress ?? data.value ?? data.percentage;
|
||||
return typeof value === "number"
|
||||
@@ -124,7 +155,12 @@ class Upscaler {
|
||||
|
||||
const { cmd } = data;
|
||||
|
||||
if (cmd === "progress") {
|
||||
if (cmd === "error") {
|
||||
const errorMsg =
|
||||
data.data || data.message || "Unknown worker error";
|
||||
console.error("[Upscaler] Worker reported error:", errorMsg);
|
||||
this.rejectCurrent(new Error(errorMsg));
|
||||
} else if (cmd === "progress") {
|
||||
const progress = Upscaler.extractProgressValue(data);
|
||||
if (progress !== null && this.progressCallback) {
|
||||
this.progressCallback(progress);
|
||||
@@ -215,15 +251,22 @@ class Upscaler {
|
||||
createBlobPromise(blobType, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.currentReject = null;
|
||||
this.currentTimeoutId = null;
|
||||
if (this.messageHandlers[blobType]) {
|
||||
delete this.messageHandlers[blobType];
|
||||
reject(new Error(`${blobType} processing timeout`));
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
this.currentReject = reject;
|
||||
this.currentTimeoutId = timeoutId;
|
||||
|
||||
this.messageHandlers[blobType] = (msg) => {
|
||||
delete this.messageHandlers[blobType];
|
||||
clearTimeout(timeoutId);
|
||||
this.currentReject = null;
|
||||
this.currentTimeoutId = null;
|
||||
const blob = msg[blobType] || msg.data;
|
||||
if (blob instanceof Blob) {
|
||||
resolve(blob);
|
||||
@@ -248,6 +291,9 @@ class Upscaler {
|
||||
const size = networkSize || this.networkSize;
|
||||
this.validateNetworkSize(size);
|
||||
|
||||
// Reject any stale promise from a previous aborted operation
|
||||
this.rejectCurrent(new Error("Aborted by new operation"));
|
||||
|
||||
this.processingType = "image";
|
||||
const arrayBuffer = await imageFile.arrayBuffer();
|
||||
const mimeType = imageFile.type || "image/jpeg";
|
||||
@@ -319,6 +365,9 @@ class Upscaler {
|
||||
throw new Error("WebCodecs API not supported. Requires Chrome 94+");
|
||||
}
|
||||
|
||||
// Reject any stale promise from a previous aborted operation
|
||||
this.rejectCurrent(new Error("Aborted by new operation"));
|
||||
|
||||
this.processingType = "video";
|
||||
const { width, height, duration } = await Upscaler.loadVideoMetadata(
|
||||
videoFile,
|
||||
@@ -599,6 +648,65 @@ class Upscaler {
|
||||
}
|
||||
}
|
||||
|
||||
async upscaleVideoBatch(files, options = {}) {
|
||||
const {
|
||||
networkSize,
|
||||
directoryHandle,
|
||||
onFileStart,
|
||||
onFileProgress,
|
||||
onFileComplete,
|
||||
onFileError,
|
||||
} = options;
|
||||
|
||||
const results = [];
|
||||
const size = networkSize || this.networkSize;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file.type.startsWith("video/")) {
|
||||
const err = new Error(`Skipped (not a video): ${file.name}`);
|
||||
if (onFileError) onFileError(file, i, err);
|
||||
results.push({ file, error: err, success: false });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (onFileStart) onFileStart(file, i, files.length);
|
||||
|
||||
this.onProgress((progress) => {
|
||||
if (onFileProgress) onFileProgress(file, i, progress);
|
||||
});
|
||||
|
||||
const blob = await this.upscaleVideo(file, size);
|
||||
|
||||
if (directoryHandle) {
|
||||
const outName = this._buildOutputName(file.name);
|
||||
const fh = await directoryHandle.getFileHandle(outName, {
|
||||
create: true,
|
||||
});
|
||||
const writable = await fh.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
if (onFileComplete) onFileComplete(file, i, blob);
|
||||
results.push({ file, blob, success: true });
|
||||
} catch (error) {
|
||||
if (onFileError) onFileError(file, i, error);
|
||||
results.push({ file, error, success: false });
|
||||
}
|
||||
}
|
||||
|
||||
this.progressCallback = null;
|
||||
return results;
|
||||
}
|
||||
|
||||
_buildOutputName(originalName) {
|
||||
const dot = originalName.lastIndexOf(".");
|
||||
const base = dot > 0 ? originalName.slice(0, dot) : originalName;
|
||||
return `${base}_upscaled.mp4`;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.stopRealtimeUpscale();
|
||||
if (this.workerInstance) {
|
||||
|
||||
@@ -1764,7 +1764,7 @@ self.onmessage = async (event) => {
|
||||
: { width: 2 * h.width, height: 2 * h.height };
|
||||
let y,
|
||||
U,
|
||||
C = (b.width * b.height * 25e5) / 921600;
|
||||
C = (b.width * b.height * 8e6) / 921600;
|
||||
const R = await E(r);
|
||||
if (t || a) {
|
||||
const e = a ? w : h;
|
||||
@@ -1836,7 +1836,7 @@ self.onmessage = async (event) => {
|
||||
});
|
||||
const z = new B.fX(D);
|
||||
let I = b.width * b.height > 921600 ? "avc1.42003e" : "avc1.42001f",
|
||||
O = ["avc1.640028", "avc1.64001e", "avc1.42003e", "avc1.42001f"];
|
||||
O = ["avc1.640033", "avc1.640028", "avc1.64001e", "avc1.640034", "avc1.42003e", "avc1.42001f"];
|
||||
"firefox" === q && (O = ["avc1.42003e", "avc1.42001f"]);
|
||||
for (const e of O) {
|
||||
const t = {
|
||||
@@ -1857,6 +1857,7 @@ self.onmessage = async (event) => {
|
||||
height: b.height,
|
||||
bitrate: Math.round(C),
|
||||
framerate: 1e6 / F[0].duration,
|
||||
latencyMode: "quality",
|
||||
},
|
||||
N = [],
|
||||
V = {},
|
||||
@@ -2147,7 +2148,7 @@ self.onmessage = async (event) => {
|
||||
: { width: 2 * d.width, height: 2 * d.height };
|
||||
let w,
|
||||
b,
|
||||
y = (g.width * g.height * 25e5) / 921600;
|
||||
y = (g.width * g.height * 8e6) / 921600;
|
||||
const k = await E(r);
|
||||
if (t || a) {
|
||||
const e = a ? v : d;
|
||||
@@ -2207,8 +2208,10 @@ self.onmessage = async (event) => {
|
||||
const L = new B.fX(F);
|
||||
let M = g.width * g.height > 921600 ? "avc1.42003e" : "avc1.42001f";
|
||||
const G = [
|
||||
"avc1.640033",
|
||||
"avc1.640028",
|
||||
"avc1.64001e",
|
||||
"avc1.640034",
|
||||
"avc1.42003e",
|
||||
"avc1.42001f",
|
||||
];
|
||||
@@ -2231,6 +2234,7 @@ self.onmessage = async (event) => {
|
||||
height: g.height,
|
||||
bitrate: Math.round(y),
|
||||
framerate: 1e6 / w.encoded_chunks[0].duration,
|
||||
latencyMode: "quality",
|
||||
},
|
||||
D = [],
|
||||
z = new VideoDecoder({
|
||||
@@ -2523,7 +2527,7 @@ self.onmessage = async (event) => {
|
||||
const B = new W.r({ format: new N.E1(), target: k }),
|
||||
E = new V.D0(f, {
|
||||
codec: "avc",
|
||||
bitrate: X.n1,
|
||||
bitrate: Math.round(X.n1 * 2),
|
||||
keyFrameInterval: 60,
|
||||
}),
|
||||
U = await w.getPrimaryVideoTrack();
|
||||
@@ -2675,7 +2679,7 @@ self.onmessage = async (event) => {
|
||||
U = performance.now();
|
||||
console.log("Time to get WebSR: " + (U - g));
|
||||
const C = new W.r({ format: new N.E1(), target: E }),
|
||||
P = new V.Dg({ codec: "avc", bitrate: X.n1, keyFrameInterval: 60 }),
|
||||
P = new V.Dg({ codec: "avc", bitrate: Math.round(X.n1 * 2), keyFrameInterval: 60 }),
|
||||
R = await y.getPrimaryVideoTrack();
|
||||
if (!R && !R)
|
||||
return postMessage({
|
||||
|
||||
Reference in new issue
Block a user