Files
ArtPlayer/docs/upscaler/index.html
T
糖猫猫andrionzhao 40fcda6a37 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>
2026-09-10 17:19:38 +08:00

2220 lines
103 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="google-adsense-account" content="ca-pub-8579453810848662" />
<title>
Free AI Video Upscaler - Super Resolution in Browser | No Upload Required
</title>
<meta
name="description"
content="Free AI video upscaler with super resolution. Enhance and upscale videos directly in your browser. No upload, no server, 100% private. Supports WebGPU/WebGL acceleration."
/>
<meta
name="keywords"
content="AI video upscaler, video enhancement, browser video upscaler, no upload video enhancer, private video upscaling, super resolution, WebGPU upscaler, free video quality enhancement, client-side video processing, 4K upscaling, local video enhancer, video quality improver"
/>
<meta name="robots" content="index,follow" />
<meta name="author" content="ArtPlayer" />
<link rel="canonical" href="https://artplayer.org/upscaler/" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://artplayer.org/upscaler/" />
<meta
property="og:title"
content="Free AI Video Upscaler - No Upload, 100% Private"
/>
<meta
property="og:description"
content="AI video upscaler runs entirely in your browser. No upload needed, completely private and secure."
/>
<meta property="og:site_name" content="ArtPlayer" />
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
content="Free AI Video Upscaler - No Upload, 100% Private"
/>
<meta
name="twitter:description"
content="AI video upscaler runs entirely in your browser. No upload needed, completely private and secure."
/>
<link
rel="shortcut icon"
href="/assets/img/favicon.ico"
type="image/x-icon"
/>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "AI Video Upscaler",
"description": "Free AI-powered video upscaler that runs entirely in your browser. No upload required, completely private.",
"url": "https://artplayer.org/upscaler/",
"applicationCategory": "MultimediaApplication",
"operatingSystem": "Any",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": [
"100% Local Processing",
"No File Upload Required",
"AI Super Resolution",
"WebGPU/WebGL Acceleration",
"Privacy Focused"
]
}
</script>
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-8579453810848662"
crossorigin="anonymous"
></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"sans-serif",
],
},
},
},
};
</script>
</head>
<body
class="min-h-screen flex flex-col bg-slate-50 text-slate-900 antialiased font-sans"
>
<main
class="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6 py-8 sm:py-12"
>
<!-- Title Section -->
<div class="text-center mb-8">
<h1
class="text-2xl sm:text-3xl font-bold text-slate-900 mb-3 tracking-tight"
>
AI Video Upscaler
</h1>
<p
class="text-slate-500 text-sm sm:text-base max-w-md mx-auto leading-relaxed"
>
Enhance video quality with AI super resolution. Runs entirely
in your browser — no upload, completely private.
</p>
</div>
<!-- Upload Card -->
<div
class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden"
>
<div class="p-4 sm:p-6">
<!-- Upload Area -->
<div class="mb-6">
<input
type="file"
id="universalInput"
accept="video/*"
multiple
class="sr-only"
/>
<input
type="file"
id="folderInput"
webkitdirectory
directory
class="sr-only"
/>
<div
class="group relative flex flex-col w-full aspect-video cursor-pointer items-center justify-center rounded-xl border-2 border-dashed border-slate-300 overflow-hidden bg-gradient-to-br from-slate-50 to-gray-100 transition-all duration-200 hover:from-blue-50 hover:to-blue-100 hover:border-blue-300"
id="uploadLabel"
ondrop="handleDrop(event)"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)"
>
<!-- Upload Placeholder -->
<div
id="uploadPlaceholder"
class="flex flex-col items-center px-4"
>
<div
class="w-12 h-12 sm:w-14 sm:h-14 rounded-full bg-slate-100 flex items-center justify-center mb-3 sm:mb-4"
>
<svg
class="w-6 h-6 sm:w-7 sm:h-7 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<span
class="text-slate-700 font-medium text-sm sm:text-base mb-1"
>Choose videos to upscale</span
>
<span
class="text-slate-400 text-xs sm:text-sm mb-4"
>Drag & drop, or use the buttons below</span
>
<div class="flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onclick="event.preventDefault(); document.getElementById('universalInput').click()"
class="inline-flex items-center gap-2 px-4 py-2 text-xs sm:text-sm font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-all"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Select Files
</button>
<button
type="button"
onclick="event.preventDefault(); document.getElementById('folderInput').click()"
class="inline-flex items-center gap-2 px-4 py-2 text-xs sm:text-sm font-semibold text-slate-700 bg-white hover:bg-slate-50 border border-slate-300 rounded-lg transition-all"
>
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
Select Folder
</button>
</div>
<button
type="button"
onclick="loadSampleVideo(event)"
class="inline-flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-semibold text-white bg-blue-500 hover:bg-blue-600 rounded-lg transition-all"
>
<svg
class="w-3.5 h-3.5 sm:w-4 sm:h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Try sample
</button>
</div>
<!-- Video Preview (shown after file selection) -->
<div
id="videoPreviewArea"
class="absolute inset-0 hidden bg-slate-900"
>
<video
id="videoPreviewPlayer"
class="w-full h-full object-contain pointer-events-none"
muted
playsinline
preload="metadata"
></video>
<!-- Video Info Overlay -->
<div
class="absolute top-3 left-3 flex items-center gap-2"
>
<span
class="inline-flex items-center gap-1.5 bg-black/50 backdrop-blur-sm text-white text-xs font-medium px-2.5 py-1 rounded-md"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<span id="videoMetaInfo"></span>
</span>
</div>
<!-- File Name Overlay -->
<div
class="absolute bottom-3 left-3 right-3 flex items-center justify-between gap-2"
>
<span
id="videoFileName"
class="text-white text-sm font-medium truncate min-w-0 drop-shadow-md"
></span>
<span
class="text-white/80 text-xs flex-shrink-0"
>Click to browse</span
>
</div>
</div>
</div>
</div>
<!-- Usage Tips -->
<div
class="mb-6 rounded-xl border border-blue-100 bg-blue-50/60 p-4"
>
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="flex-1">
<p
class="text-sm font-medium text-slate-700 mb-2"
>
Usage Guide
</p>
<ul
class="space-y-1.5 text-xs text-slate-600 leading-relaxed"
>
<li class="flex items-start gap-2">
<span
class="text-blue-400 font-bold flex-shrink-0"
>1.</span
>
<span
><strong
>Single file</strong
> — click "Select Files" and
choose one video to upscale and
preview with side-by-side
comparison.</span
>
</li>
<li class="flex items-start gap-2">
<span
class="text-blue-400 font-bold flex-shrink-0"
>2.</span
>
<span
><strong>Multiple files</strong
> — click "Select Files" and pick
multiple videos, or drag & drop
several files at once. They will
be processed sequentially.</span
>
</li>
<li class="flex items-start gap-2">
<span
class="text-blue-400 font-bold flex-shrink-0"
>3.</span
>
<span
><strong>Entire folder</strong
> — click "Select Folder" to pick
a directory. All videos inside
will be found and processed in
order.</span
>
</li>
<li class="flex items-start gap-2">
<span
class="text-blue-400 font-bold flex-shrink-0"
>4.</span
>
<span
><strong>Save location</strong
> — click "Choose folder…" to
auto-save all results to a
directory. Otherwise, use the
Download button per file.</span
>
</li>
<li class="flex items-start gap-2">
<span
class="text-blue-400 font-bold flex-shrink-0"
>5.</span
>
<span
><strong>Compare results</strong
> — after batch processing, click
any item in the right-side
result list to load it into the
comparison viewer.</span
>
</li>
</ul>
<div
class="mt-3 pt-3 border-t border-blue-100"
>
<p class="text-xs text-slate-500">
<span class="font-medium">Tip:</span>
Folder upload and directory save
require Chrome or Edge. Processing
happens locally — your videos never
leave your device.
</p>
</div>
</div>
</div>
</div>
<!-- Network Selection -->
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 mb-6"
>
<div>
<label
class="block text-sm font-medium text-slate-700 mb-0.5 sm:mb-1"
>
Quality Model
</label>
<p class="text-xs text-slate-400">
Higher = better quality, slower
</p>
</div>
<div
class="inline-flex items-center gap-0.5 sm:gap-1 rounded-lg bg-slate-100 p-1 self-start sm:self-auto"
id="networkToggle"
>
<button
type="button"
data-network="small"
class="rounded-md px-2.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium text-slate-600 transition-all duration-150"
>
Fast
</button>
<button
type="button"
data-network="medium"
class="rounded-md px-2.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium bg-slate-800 text-white shadow-sm transition-all duration-150"
>
Balanced
</button>
<button
type="button"
data-network="large"
class="rounded-md px-2.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium text-slate-600 transition-all duration-150"
>
Quality
</button>
</div>
</div>
<!-- Save Directory Selection -->
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 mb-6"
>
<div>
<label
class="block text-sm font-medium text-slate-700 mb-0.5 sm:mb-1"
>
Save Directory
</label>
<p class="text-xs text-slate-400">
Results auto-saved to this folder
</p>
</div>
<button
type="button"
id="selectDirBtn"
onclick="selectSaveDirectory()"
class="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs sm:text-sm font-medium text-slate-700 hover:bg-slate-50 transition-all self-start sm:self-auto"
>
<svg
class="w-4 h-4 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
<span id="dirLabel">Choose folder…</span>
</button>
</div>
<!-- Batch File List -->
<div id="batchFileList" class="hidden mb-6">
<div class="flex items-center justify-between mb-2">
<span
class="text-sm font-medium text-slate-700"
id="batchListTitle"
>Files</span
>
<span
class="text-xs text-slate-400"
id="batchListCount"
></span>
</div>
<div
id="batchFileItems"
class="space-y-2 max-h-64 overflow-y-auto"
></div>
</div>
<!-- Process Button -->
<button
id="processBtn"
onclick="handleBatchProcess()"
disabled
class="relative w-full rounded-xl px-4 py-3.5 text-sm font-semibold text-white overflow-hidden bg-gradient-to-b from-slate-800 to-slate-900 transition-all duration-200 hover:from-slate-700 hover:to-slate-800 hover:shadow-lg disabled:bg-slate-200 disabled:from-slate-200 disabled:to-slate-200 disabled:text-slate-400 disabled:shadow-none"
>
<span
class="progress-fill absolute inset-y-0 left-0 w-0 bg-emerald-600 transition-all duration-200 ease-out z-0"
id="progressFill"
></span>
<span id="progressText" class="relative z-10">
Select videos to start
</span>
</button>
</div>
<!-- Result Section -->
<div
class="border-t border-slate-100 bg-slate-50 p-5 sm:p-8 hidden"
id="resultSection"
>
<div class="flex items-center gap-2 mb-4">
<svg
class="w-5 h-5 text-emerald-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm font-medium text-slate-700"
>Upscaling Complete</span
>
</div>
<div id="videoResultSection" class="hidden">
<div class="flex flex-col lg:flex-row gap-5">
<!-- Left: Comparison Viewer -->
<div class="flex-1 min-w-0">
<div id="videoComparisonViewer" class="mb-5">
<div
class="comparison-container relative w-full max-w-full rounded-xl overflow-hidden bg-slate-900 shadow-md select-none"
id="videoComparisonContainer"
>
<video
id="resultVideoEnhanced"
class="comparison-img enhanced block w-full h-auto bg-slate-900"
loop
muted
playsinline
preload="metadata"
>
<source
id="resultVideoSourceEnhanced"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
<video
id="resultVideoOriginal"
class="comparison-img original absolute inset-0 w-full h-full bg-slate-900 object-contain"
loop
playsinline
preload="metadata"
>
<source
id="resultVideoSourceOriginal"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
<div
class="comparison-handle absolute left-1/2 top-0 h-full w-10 -translate-x-1/2 cursor-ew-resize z-10 flex items-center justify-center"
id="videoComparisonHandle"
>
<div
class="absolute inset-y-0 left-1/2 w-[2px] -translate-x-1/2 bg-white/90 shadow-[0_0_8px_rgba(0,0,0,0.4)]"
></div>
<div
class="relative h-9 w-9 rounded-full bg-white shadow-lg border border-slate-200 flex items-center justify-center cursor-ew-resize"
id="videoComparisonKnob"
>
<svg
class="w-4 h-4 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7 7-7m4 14l7-7-7-7"
/>
</svg>
</div>
</div>
<div
class="comparison-labels absolute top-3 w-full flex justify-between px-3 pointer-events-none z-10"
>
<div
class="bg-slate-900/70 px-2.5 py-1 rounded-md text-xs font-medium text-white backdrop-blur-sm"
>
Original
</div>
<div
class="bg-slate-900/70 px-2.5 py-1 rounded-md text-xs font-medium text-white backdrop-blur-sm"
>
Upscaled
</div>
</div>
<!-- Play/Pause Button -->
<button
id="comparisonPlayPauseBtn"
onclick="toggleComparisonPlayPause(event)"
class="absolute bottom-3 left-3 w-9 h-9 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-full text-white hover:bg-black/70 transition-all z-20"
title="Play/Pause"
>
<svg id="comparisonPlayIcon" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<svg id="comparisonPauseIcon" class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</button>
</div>
</div>
<!-- File Info -->
<div
class="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 mb-5 text-center"
>
<div
class="bg-white rounded-lg px-2 sm:px-3 py-2.5 sm:py-3 border border-slate-200"
>
<div
class="text-[10px] sm:text-xs text-slate-400 mb-0.5 sm:mb-1"
>
Original
</div>
<div
class="text-xs sm:text-sm font-medium text-slate-700"
id="originalVideoSize"
>
-
</div>
</div>
<div
class="bg-white rounded-lg px-2 sm:px-3 py-2.5 sm:py-3 border border-slate-200"
>
<div
class="text-[10px] sm:text-xs text-slate-400 mb-0.5 sm:mb-1"
>
Upscaled
</div>
<div
class="text-xs sm:text-sm font-medium text-slate-700"
id="resultVideoSize"
>
-
</div>
</div>
<div
class="bg-white rounded-lg px-2 sm:px-3 py-2.5 sm:py-3 border border-slate-200"
>
<div
class="text-[10px] sm:text-xs text-slate-400 mb-0.5 sm:mb-1"
>
Duration
</div>
<div
class="text-xs sm:text-sm font-medium text-slate-700"
id="durationVideoInfo"
>
-
</div>
</div>
<div
class="bg-white rounded-lg px-2 sm:px-3 py-2.5 sm:py-3 border border-slate-200"
>
<div
class="text-[10px] sm:text-xs text-slate-400 mb-0.5 sm:mb-1"
>
Backend
</div>
<div
class="text-xs sm:text-sm font-medium text-slate-700"
id="backendVideoInfo"
>
-
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3">
<button
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white hover:bg-slate-800 transition-colors"
onclick="downloadResult()"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download
</button>
<button
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors"
onclick="resetUpscalerUI()"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Start Over
</button>
</div>
</div>
<!-- Right: Batch Result List -->
<div
id="batchResultPanel"
class="hidden w-full lg:w-72 lg:flex-shrink-0"
>
<div
class="bg-white rounded-xl border border-slate-200 p-4 sticky top-4"
>
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold text-slate-700"
>Results</span
>
<span
class="text-xs text-slate-400"
id="batchResultCount"
></span
>
</div>
<div
id="batchResultItems"
class="space-y-2 max-h-[420px] overflow-y-auto"
></div>
<p
class="text-xs text-slate-400 mt-3 leading-relaxed"
>
Click a result to load it into the
comparison viewer.
</p>
</div>
</div>
</div>
<!-- Comparison Data Panel (full width) -->
<div
id="comparisonDataPanel"
class="hidden mt-5 rounded-xl border border-slate-200"
>
<div
class="bg-slate-50 px-4 py-2 border-b border-slate-200"
>
<span
class="text-xs font-semibold text-slate-600 uppercase tracking-wide"
>Video Data Comparison</span
>
</div>
<table
class="w-full text-sm border-collapse"
>
<thead>
<tr class="border-b border-slate-200">
<th
class="text-left px-4 py-2 font-medium text-slate-400 text-xs"
>
Metric
</th>
<th
class="text-right px-4 py-2 font-medium text-slate-400 text-xs"
>
Original
</th>
<th
class="text-right px-4 py-2 font-medium text-slate-400 text-xs"
>
Upscaled
</th>
<th
class="text-right px-4 py-2 font-medium text-slate-400 text-xs"
>
Change
</th>
</tr>
</thead>
<tbody
id="comparisonDataBody"
class="divide-y divide-slate-100"
></tbody>
</table>
</div>
</div>
<!-- Features Section for SEO -->
<section
class="mt-12 grid grid-cols-2 sm:grid-cols-4 gap-4 text-center"
>
<div class="p-4">
<div
class="w-10 h-10 mx-auto mb-2 rounded-full bg-emerald-100 flex items-center justify-center"
>
<svg
class="w-5 h-5 text-emerald-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h3 class="text-sm font-semibold text-slate-800">
100% Local
</h3>
<p class="text-xs text-slate-500 mt-1">No server upload</p>
</div>
<div class="p-4">
<div
class="w-10 h-10 mx-auto mb-2 rounded-full bg-blue-100 flex items-center justify-center"
>
<svg
class="w-5 h-5 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h3 class="text-sm font-semibold text-slate-800">
Private
</h3>
<p class="text-xs text-slate-500 mt-1">
Files stay on device
</p>
</div>
<div class="p-4">
<div
class="w-10 h-10 mx-auto mb-2 rounded-full bg-purple-100 flex items-center justify-center"
>
<svg
class="w-5 h-5 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<h3 class="text-sm font-semibold text-slate-800">
GPU Accelerated
</h3>
<p class="text-xs text-slate-500 mt-1">WebGPU / WebGL</p>
</div>
<div class="p-4">
<div
class="w-10 h-10 mx-auto mb-2 rounded-full bg-amber-100 flex items-center justify-center"
>
<svg
class="w-5 h-5 text-amber-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 class="text-sm font-semibold text-slate-800">
Free Forever
</h3>
<p class="text-xs text-slate-500 mt-1">No watermark</p>
</div>
</section>
</main>
<!-- Footer -->
<footer class="w-full border-t border-slate-200 bg-white">
<div
class="max-w-4xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row items-center justify-center sm:justify-between gap-1 sm:gap-0 text-xs sm:text-sm text-slate-500"
>
<span>Powered by WebGPU / WebGL</span>
<span
>Open source on
<a
href="https://github.com/zhw2590582/ArtPlayer"
class="text-slate-700 hover:underline"
>GitHub</a
></span
>
</div>
</footer>
<script src="upscaler.js"></script>
<script>
var _hmt = _hmt || [];
var hm = document.createElement("script");
hm.src =
"https://hm.baidu.com/hm.js?879b2890d8d025c751785170763ca82b";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
</script>
<script>
let currentResultBlob = null;
let currentOriginalUrl = null;
let isComparisonDragging = false;
let comparisonCleanup = null;
let currentVideoFiles = [];
let saveDirectoryHandle = null;
let isBatchProcessing = false;
let batchResults = [];
let selectedResultIndex = -1;
let batchFileStatuses = [];
// Create a configurable Upscaler instance
const upscaler = new Upscaler({
networkSize: "medium",
weightsBaseUrl: "/upscaler/weights",
workerUrl: "/upscaler/worker/main.js",
});
function updateComparisonPosition(element, rect, x) {
x = Math.max(0, Math.min(x, rect.width));
const percentage = (x / rect.width) * 100;
return { x, percentage };
}
function applyMaskToElement(element, percentage) {
const maskGradient = `linear-gradient(to right, black 0%, black ${percentage}%, transparent ${percentage}%, transparent 100%)`;
element.style.maskImage = maskGradient;
element.style.webkitMaskImage = maskGradient;
}
function setupComparisonDrag(container, handle, originalElement) {
// Return cleanup function to remove listeners
const abortController = new AbortController();
const { signal } = abortController;
function updatePosition(e) {
if (!isComparisonDragging) return;
const rect = container.getBoundingClientRect();
let x = e.clientX - rect.left;
if (e.touches) {
x = e.touches[0].clientX - rect.left;
}
const { percentage } = updateComparisonPosition(
originalElement,
rect,
x,
);
handle.style.left = x + "px";
applyMaskToElement(originalElement, percentage);
}
function startDrag(e) {
e.preventDefault();
isComparisonDragging = true;
container.style.cursor = "ew-resize";
}
function stopDrag() {
isComparisonDragging = false;
container.style.cursor = "default";
}
handle.addEventListener("mousedown", startDrag, { signal });
handle.addEventListener("touchstart", startDrag, {
passive: false,
signal,
});
document.addEventListener("mousemove", updatePosition, {
signal,
});
document.addEventListener("touchmove", updatePosition, {
passive: false,
signal,
});
document.addEventListener("mouseup", stopDrag, { signal });
document.addEventListener("touchend", stopDrag, { signal });
container.addEventListener(
"click",
(e) => {
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const { percentage } = updateComparisonPosition(
originalElement,
rect,
x,
);
handle.style.left = x + "px";
applyMaskToElement(originalElement, percentage);
},
{ signal },
);
applyMaskToElement(originalElement, 50);
handle.style.left = container.offsetWidth / 2 + "px";
return () => abortController.abort();
}
function initVideoComparisonDrag() {
// Cleanup previous listeners
if (comparisonCleanup) {
comparisonCleanup();
comparisonCleanup = null;
}
const container = document.getElementById(
"videoComparisonContainer",
);
const handle = document.getElementById("videoComparisonHandle");
const originalVideo = document.getElementById(
"resultVideoOriginal",
);
const enhancedVideo = document.getElementById(
"resultVideoEnhanced",
);
// Use AbortController for video sync event listeners
const syncController = new AbortController();
const { signal } = syncController;
enhancedVideo.addEventListener(
"play",
() => originalVideo.play(),
{ signal },
);
enhancedVideo.addEventListener(
"pause",
() => originalVideo.pause(),
{ signal },
);
enhancedVideo.addEventListener(
"timeupdate",
() => {
if (
Math.abs(
originalVideo.currentTime -
enhancedVideo.currentTime,
) > 0.1
) {
originalVideo.currentTime = enhancedVideo.currentTime;
}
},
{ signal },
);
enhancedVideo.addEventListener(
"seeking",
() => {
originalVideo.currentTime = enhancedVideo.currentTime;
},
{ signal },
);
// Initialize drag handle
const dragCleanup = setupComparisonDrag(
container,
handle,
originalVideo,
);
// Combined cleanup: abort sync listeners + drag cleanup
comparisonCleanup = () => {
syncController.abort();
if (dragCleanup) dragCleanup();
};
}
function formatVideoDuration(seconds) {
if (!seconds || isNaN(seconds)) return "-";
if (seconds < 60) return `${Math.floor(seconds)}s`;
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
async function loadSampleVideo(e) {
e.preventDefault();
e.stopPropagation();
const sampleUrl =
"https://artplayer.org/assets/sample/test1.mp4";
const btn = e.currentTarget;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
`;
try {
const response = await fetch(sampleUrl);
const blob = await response.blob();
const file = new File([blob], "sample_video.mp4", {
type: "video/mp4",
});
// Create a DataTransfer to set files on input
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
document.getElementById("universalInput").files =
dataTransfer.files;
document
.getElementById("universalInput")
.dispatchEvent(new Event("change"));
} catch (error) {
console.error("Failed to load sample video:", error);
alert("Failed to load sample video. Please try again.");
btn.disabled = false;
btn.innerHTML = originalText;
}
}
let previewVideoUrl = null;
function showVideoPreview(file) {
const placeholder =
document.getElementById("uploadPlaceholder");
const previewArea = document.getElementById("videoPreviewArea");
const previewVideo =
document.getElementById("videoPreviewPlayer");
const fileName = document.getElementById("videoFileName");
const metaInfo = document.getElementById("videoMetaInfo");
const uploadLabel = document.getElementById("uploadLabel");
if (!placeholder || !previewArea || !previewVideo) return;
// Revoke previous URL if exists
if (previewVideoUrl) {
URL.revokeObjectURL(previewVideoUrl);
}
// Create new URL for the video
previewVideoUrl = URL.createObjectURL(file);
previewVideo.src = previewVideoUrl;
previewVideo.pause();
// Show preview area, hide placeholder
placeholder.classList.add("hidden");
previewArea.classList.remove("hidden");
// Hide border completely
uploadLabel.classList.remove(
"border-dashed",
"border-2",
"border-slate-300",
);
uploadLabel.classList.add("border-0");
fileName.textContent = file.name;
metaInfo.textContent = "Loading...";
previewVideo.onloadedmetadata = () => {
const width = previewVideo.videoWidth;
const height = previewVideo.videoHeight;
const duration = previewVideo.duration;
metaInfo.textContent = `${width}×${height} · ${formatVideoDuration(duration)}`;
};
}
function resetVideoPreview() {
const placeholder =
document.getElementById("uploadPlaceholder");
const previewArea = document.getElementById("videoPreviewArea");
const previewVideo =
document.getElementById("videoPreviewPlayer");
const uploadLabel = document.getElementById("uploadLabel");
if (placeholder) placeholder.classList.remove("hidden");
if (previewArea) previewArea.classList.add("hidden");
if (previewVideo) {
previewVideo.pause();
previewVideo.src = "";
}
// Restore dashed border
if (uploadLabel) {
uploadLabel.classList.remove("border-0");
uploadLabel.classList.add(
"border-dashed",
"border-2",
"border-slate-300",
);
}
if (previewVideoUrl) {
URL.revokeObjectURL(previewVideoUrl);
previewVideoUrl = null;
}
}
// Watch file input — supports batch selection
(function initFileInputWatcher() {
const fileInput = document.getElementById("universalInput");
const processBtn = document.getElementById("processBtn");
if (!fileInput || !processBtn) return;
processBtn.disabled = true;
fileInput.addEventListener("change", async () => {
const allFiles = Array.from(fileInput.files);
if (allFiles.length === 0) return;
const videoFiles = allFiles.filter((f) =>
f.type.startsWith("video/"),
);
if (videoFiles.length === 0) {
alert(
"Only video files are supported, please choose again.",
);
fileInput.value = "";
return;
}
currentVideoFiles = videoFiles;
processBtn.disabled = false;
document.getElementById("progressText").textContent =
videoFiles.length === 1
? "Start upscaling"
: `Start upscaling ${videoFiles.length} videos`;
renderBatchFileList();
showVideoPreview(videoFiles[0]);
});
})();
// Watch folder input — uploads entire folder
(function initFolderInputWatcher() {
const folderInput =
document.getElementById("folderInput");
const processBtn = document.getElementById("processBtn");
if (!folderInput || !processBtn) return;
folderInput.addEventListener("change", async () => {
const allFiles = Array.from(folderInput.files);
if (allFiles.length === 0) return;
// Filter video files from the folder
const videoFiles = allFiles.filter((f) =>
f.type.startsWith("video/"),
);
if (videoFiles.length === 0) {
alert(
"No video files found in the selected folder.",
);
folderInput.value = "";
return;
}
// Sort by path for consistent ordering
videoFiles.sort((a, b) =>
(a.webkitRelativePath || a.name).localeCompare(
b.webkitRelativePath || b.name,
),
);
currentVideoFiles = videoFiles;
processBtn.disabled = false;
document.getElementById("progressText").textContent =
`Start upscaling ${videoFiles.length} videos`;
renderBatchFileList();
showVideoPreview(videoFiles[0]);
folderInput.value = "";
});
})();
// Drag & drop support for files and folders
function handleDragOver(event) {
event.preventDefault();
event.currentTarget.classList.add(
"border-blue-400",
"bg-blue-50",
);
}
function handleDragLeave(event) {
event.preventDefault();
event.currentTarget.classList.remove(
"border-blue-400",
"bg-blue-50",
);
}
async function handleDrop(event) {
event.preventDefault();
event.currentTarget.classList.remove(
"border-blue-400",
"bg-blue-50",
);
const items = event.dataTransfer.items;
if (items && items.length > 0 && items[0].webkitGetAsEntry) {
// Folder drag-drop via FileSystemEntry API
const entries = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry();
if (entry) entries.push(entry);
}
const files = await collectFilesFromEntries(entries);
if (files.length > 0) {
acceptVideoFiles(files);
} else {
alert("No video files found in dropped folder.");
}
} else {
// Plain file drag-drop
const files = Array.from(event.dataTransfer.files);
acceptVideoFiles(files);
}
}
// Recursively collect files from FileSystemEntry list
function collectFilesFromEntries(entries) {
return new Promise((resolve) => {
const files = [];
let pending = entries.length;
if (pending === 0) return resolve(files);
function checkDone() {
pending--;
if (pending === 0) resolve(files);
}
function traverseEntry(entry, path = "") {
if (entry.isFile) {
entry.file(
(file) => {
file.webkitRelativePath =
path + file.name;
files.push(file);
checkDone();
},
(err) => {
console.warn(
"Failed to read file entry:",
err,
);
checkDone();
},
);
} else if (entry.isDirectory) {
const reader = entry.createReader();
const readEntries = () => {
reader.readEntries(
(results) => {
if (results.length === 0) {
checkDone();
} else {
for (const e of results) {
traverseEntry(
e,
path + entry.name + "/",
);
}
readEntries();
}
},
(err) => {
console.warn(
"Failed to read directory entries:",
err,
);
checkDone();
},
);
};
readEntries();
} else {
checkDone();
}
}
for (const entry of entries) {
traverseEntry(entry);
}
});
}
// Accept and set video files from any source
function acceptVideoFiles(files) {
const videoFiles = files.filter((f) =>
f.type.startsWith("video/"),
);
if (videoFiles.length === 0) {
alert(
"No video files found. Please choose video files.",
);
return;
}
videoFiles.sort((a, b) =>
(a.webkitRelativePath || a.name).localeCompare(
b.webkitRelativePath || b.name,
),
);
currentVideoFiles = videoFiles;
document.getElementById("processBtn").disabled = false;
document.getElementById("progressText").textContent =
videoFiles.length === 1
? "Start upscaling"
: `Start upscaling ${videoFiles.length} videos`;
renderBatchFileList();
showVideoPreview(videoFiles[0]);
}
// Select save directory via File System Access API
async function selectSaveDirectory() {
if (!window.showDirectoryPicker) {
alert(
"File System Access API is not supported in this browser. Please use Chrome/Edge 86+.",
);
return;
}
try {
saveDirectoryHandle = await window.showDirectoryPicker({
mode: "readwrite",
});
document.getElementById("dirLabel").textContent =
saveDirectoryHandle.name;
} catch (e) {
if (e.name !== "AbortError") {
console.error("Failed to select directory:", e);
}
}
}
// Render batch file list with status badges
function renderBatchFileList() {
const container = document.getElementById("batchFileList");
const items = document.getElementById("batchFileItems");
const countLabel = document.getElementById("batchListCount");
if (!container || !items) return;
if (currentVideoFiles.length === 0) {
container.classList.add("hidden");
return;
}
container.classList.remove("hidden");
document.getElementById("batchListTitle").textContent =
currentVideoFiles.length === 1
? "1 file"
: `${currentVideoFiles.length} files`;
countLabel.textContent = "";
// Sync statuses array length
while (batchFileStatuses.length < currentVideoFiles.length) {
batchFileStatuses.push({ status: "pending", error: null });
}
items.innerHTML = currentVideoFiles
.map((file, i) => {
const relPath = file.webkitRelativePath || file.name;
const dirName = relPath.includes("/")
? relPath.substring(
0,
relPath.lastIndexOf("/"),
)
: "";
const st = batchFileStatuses[i] || {
status: "pending",
error: null,
};
const hasError =
st.status === "error" && st.error;
return `
<div class="rounded-lg border border-slate-200 bg-white px-3 py-2" id="batchItem-${i}">
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-700 truncate">${escapeHtml(file.name)}</p>
<p class="text-xs text-slate-400 truncate">${formatBytes(file.size)}${dirName ? ` · <span class="text-slate-300">${escapeHtml(dirName)}</span>` : ""}</p>
</div>
<span class="text-xs font-medium text-slate-400 px-2 py-1 rounded-md bg-slate-100" id="batchStatus-${i}">Pending</span>
</div>
<div id="batchError-${i}" class="${hasError ? "mt-2" : "hidden"}">
${hasError ? `<p class="text-xs text-red-600 bg-red-50 rounded-md px-2 py-1.5 mb-2">${escapeHtml(st.error)}</p><button type="button" onclick="retryBatchItem(${i})" class="inline-flex items-center gap-1.5 text-xs font-medium text-blue-600 hover:text-blue-700"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>Retry</button>` : ""}
</div>
</div>`;
})
.join("");
}
function updateBatchItemStatus(index, status, detail, error) {
const el = document.getElementById(`batchStatus-${index}`);
if (!el) return;
const colors = {
pending: "bg-slate-100 text-slate-400",
processing: "bg-blue-100 text-blue-700",
done: "bg-emerald-100 text-emerald-700",
error: "bg-red-100 text-red-700",
};
el.className = `text-xs font-medium px-2 py-1 rounded-md ${colors[status] || colors.pending}`;
el.textContent = detail || status;
// Store status for re-render
if (!batchFileStatuses[index]) {
batchFileStatuses[index] = { status: "pending", error: null };
}
batchFileStatuses[index].status = status;
batchFileStatuses[index].error = error || null;
// Show/hide error detail block
const errorBlock = document.getElementById(
`batchError-${index}`,
);
if (errorBlock) {
if (status === "error" && error) {
errorBlock.innerHTML = `<p class="text-xs text-red-600 bg-red-50 rounded-md px-2 py-1.5 mb-2">${escapeHtml(error)}</p><button type="button" onclick="retryBatchItem(${index})" class="inline-flex items-center gap-1.5 text-xs font-medium text-blue-600 hover:text-blue-700"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>Retry</button>`;
errorBlock.classList.remove("hidden");
} else {
errorBlock.classList.add("hidden");
errorBlock.innerHTML = "";
}
}
}
// Retry a single failed video
async function retryBatchItem(index) {
const file = currentVideoFiles[index];
if (!file) return;
if (isBatchProcessing) return;
const processBtn = document.getElementById("processBtn");
processBtn.disabled = true;
isBatchProcessing = true;
const networkToggle = document.getElementById("networkToggle");
const activeBtn = networkToggle?.querySelector(
"button.bg-slate-800[data-network]",
);
const networkSize = activeBtn?.dataset.network || "medium";
updateBatchItemStatus(index, "processing", "Retrying");
// Update preview to show the file being retried
showVideoPreview(file);
const retryStartTime = Date.now();
upscaler.onProgress((progress) => {
document.getElementById(
"progressFill",
).style.width = progress + "%";
document.getElementById(
"progressText",
).textContent = `[Retry] ${Math.round(progress)}% — ${truncateName(file.name)}`;
});
try {
const resultBlob = await upscaler.upscaleVideo(
file,
networkSize,
);
const saved = await writeToSaveDirectory(file, resultBlob);
updateBatchItemStatus(index, "done", saved ? "Saved" : "Done");
const retryDuration = Date.now() - retryStartTime;
addBatchResult(file, resultBlob, index, retryDuration);
// Load the retried result into comparison viewer
const resultIdx = batchResults.findIndex(
(r) => r.index === index,
);
if (resultIdx >= 0) {
loadResultIntoComparison(resultIdx);
}
} catch (error) {
console.error(`Retry failed for ${file.name}:`, error);
const errDetail = error.message
? error.message.substring(0, 120)
: String(error).substring(0, 120);
updateBatchItemStatus(index, "error", "Failed", errDetail);
} finally {
isBatchProcessing = false;
processBtn.disabled = false;
}
}
// Add a completed result to the right-side comparison list
function addBatchResult(file, blob, index, durationMs) {
const result = {
file,
blob,
index,
durationMs,
enhancedUrl: URL.createObjectURL(blob),
originalUrl: URL.createObjectURL(file),
};
// Replace existing entry (e.g. retry), revoking old URLs
const existing = batchResults.findIndex(
(r) => r.index === index,
);
if (existing >= 0) {
URL.revokeObjectURL(batchResults[existing].enhancedUrl);
URL.revokeObjectURL(batchResults[existing].originalUrl);
batchResults[existing] = result;
} else {
batchResults.push(result);
}
renderBatchResultList();
}
// Render the right-side result list
function renderBatchResultList() {
const panel = document.getElementById("batchResultPanel");
const container = document.getElementById(
"batchResultItems",
);
const countLabel = document.getElementById(
"batchResultCount",
);
if (!panel || !container) return;
if (batchResults.length === 0) {
panel.classList.add("hidden");
return;
}
panel.classList.remove("hidden");
countLabel.textContent = `${batchResults.length} done`;
container.innerHTML = batchResults
.map((r, i) => {
const isActive = i === selectedResultIndex;
return `
<button
type="button"
onclick="loadResultIntoComparison(${i})"
class="w-full text-left rounded-lg border px-3 py-2 transition-all ${isActive ? "border-blue-400 bg-blue-50" : "border-slate-200 bg-white hover:bg-slate-50"}"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 ${isActive ? "text-blue-500" : "text-slate-400"} flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-slate-700 truncate">${escapeHtml(r.file.name)}</p>
<p class="text-[10px] text-slate-400">${formatBytes(r.blob.size)} · ${formatDuration(r.durationMs)}</p>
</div>
</div>
</button>`;
})
.join("");
}
// Render the video data comparison table
function renderComparisonData(result, orig, enh) {
const panel = document.getElementById(
"comparisonDataPanel",
);
const body = document.getElementById("comparisonDataBody");
if (!panel || !body) return;
const origBitrate =
orig.duration > 0
? (orig.size * 8) / orig.duration / 1e6
: 0;
const enhBitrate =
enh.duration > 0
? (enh.size * 8) / enh.duration / 1e6
: 0;
const origPixels = orig.width * orig.height;
const enhPixels = enh.width * enh.height;
const rows = [
{
label: "Resolution",
orig: `${orig.width}×${orig.height}`,
enh: `${enh.width}×${enh.height}`,
change: `${((enhPixels / origPixels - 1) * 100).toFixed(0)}%`,
positive: enhPixels > origPixels,
},
{
label: "File Size",
orig: formatBytes(orig.size),
enh: formatBytes(enh.size),
change: `${((enh.size / orig.size - 1) * 100).toFixed(0)}%`,
positive: null,
},
{
label: "Duration",
orig: formatVideoDuration(orig.duration),
enh: formatVideoDuration(enh.duration),
change:
Math.abs(orig.duration - enh.duration) < 0.5
? "same"
: `${((enh.duration / orig.duration - 1) * 100).toFixed(1)}%`,
positive: null,
},
{
label: "Est. Bitrate",
orig: origBitrate.toFixed(1) + " Mbps",
enh: enhBitrate.toFixed(1) + " Mbps",
change: `${((enhBitrate / origBitrate - 1) * 100).toFixed(0)}%`,
positive: enhBitrate > origBitrate,
},
{
label: "Pixels",
orig: origPixels.toLocaleString(),
enh: enhPixels.toLocaleString(),
change: `${((enhPixels / origPixels - 1) * 100).toFixed(0)}%`,
positive: enhPixels > origPixels,
},
{
label: "Process Time",
orig: "—",
enh: formatDuration(result.durationMs),
change: "—",
positive: null,
},
];
body.innerHTML = rows
.map((row) => {
let changeClass = "text-slate-400";
let changeIcon = "";
if (row.positive === true) {
changeClass = "text-emerald-600 font-medium";
changeIcon = "↑ ";
} else if (row.positive === false) {
changeClass = "text-red-500";
changeIcon = "↓ ";
}
return `
<tr>
<td class="px-4 py-2 text-slate-600 font-medium">${row.label}</td>
<td class="px-4 py-2 text-right text-slate-500">${row.orig}</td>
<td class="px-4 py-2 text-right text-slate-700 font-medium">${row.enh}</td>
<td class="px-4 py-2 text-right ${changeClass}">${changeIcon}${row.change}</td>
</tr>`;
})
.join("");
panel.classList.remove("hidden");
}
// Load a result into the comparison viewer
function loadResultIntoComparison(index) {
const result = batchResults[index];
if (!result) return;
selectedResultIndex = index;
renderBatchResultList();
// Revoke old URLs if they exist
if (currentOriginalUrl) {
URL.revokeObjectURL(currentOriginalUrl);
currentOriginalUrl = null;
}
const enhancedSource = document.getElementById(
"resultVideoSourceEnhanced",
);
const originalSource = document.getElementById(
"resultVideoSourceOriginal",
);
enhancedSource.src = result.enhancedUrl;
enhancedSource.type = "video/mp4";
document.getElementById("resultVideoEnhanced").load();
originalSource.src = result.originalUrl;
originalSource.type = "video/mp4";
document.getElementById("resultVideoOriginal").load();
currentResultBlob = result.blob;
document.getElementById("originalVideoSize").textContent =
formatBytes(result.file.size);
document.getElementById("resultVideoSize").textContent =
formatBytes(result.blob.size);
document.getElementById("durationVideoInfo").textContent =
formatDuration(result.durationMs);
document.getElementById("backendVideoInfo").textContent =
Upscaler.getBackendType();
// Render comparison data table once both videos' metadata loads
const origV = document.getElementById("resultVideoOriginal");
const enhV = document.getElementById("resultVideoEnhanced");
let origMeta = null;
let enhMeta = null;
const tryRender = () => {
if (origMeta && enhMeta) {
renderComparisonData(result, origMeta, enhMeta);
}
};
const onOrigMeta = () => {
origMeta = {
width: origV.videoWidth,
height: origV.videoHeight,
duration: origV.duration,
size: result.file.size,
};
origV.removeEventListener("loadedmetadata", onOrigMeta);
tryRender();
};
const onEnhMeta = () => {
enhMeta = {
width: enhV.videoWidth,
height: enhV.videoHeight,
duration: enhV.duration,
size: result.blob.size,
};
enhV.removeEventListener("loadedmetadata", onEnhMeta);
tryRender();
};
if (origV.readyState >= 1) onOrigMeta();
else origV.addEventListener("loadedmetadata", onOrigMeta);
if (enhV.readyState >= 1) onEnhMeta();
else enhV.addEventListener("loadedmetadata", onEnhMeta);
const resultSection =
document.getElementById("resultSection");
const videoResultSection =
document.getElementById("videoResultSection");
resultSection.classList.remove("hidden");
videoResultSection.classList.remove("hidden");
const enhancedVideo = document.getElementById(
"resultVideoEnhanced",
);
const initComparison = () => {
initVideoComparisonDrag();
};
if (enhancedVideo.readyState >= 2) {
initComparison();
} else {
enhancedVideo.addEventListener(
"loadeddata",
initComparison,
{ once: true },
);
}
}
// Network size toggle buttons (small / medium / large)
(function initNetworkToggle() {
const toggle = document.getElementById("networkToggle");
if (!toggle) return;
const activeClasses = [
"bg-slate-800",
"text-white",
"shadow-sm",
];
const inactiveClasses = ["text-slate-600"];
toggle.addEventListener("click", (event) => {
const button = event.target.closest("button[data-network]");
if (!button) return;
const buttons = toggle.querySelectorAll(
"button[data-network]",
);
buttons.forEach((btn) => {
btn.classList.remove(...activeClasses);
btn.classList.add(...inactiveClasses);
});
button.classList.remove(...inactiveClasses);
button.classList.add(...activeClasses);
});
})();
// Note: drag & drop is handled by inline ondrop/ondragover/ondragleave
// on the uploadLabel div, which calls handleDrop/handleDragOver/handleDragLeave.
// This supports both file and folder drag-drop via webkitGetAsEntry.
async function handleBatchProcess() {
const files = currentVideoFiles;
if (!files || files.length === 0) {
alert("Please choose video files.");
return;
}
if (isBatchProcessing) return;
const networkToggle = document.getElementById("networkToggle");
const activeBtn = networkToggle?.querySelector(
"button.bg-slate-800[data-network]",
);
const networkSize = activeBtn?.dataset.network || "medium";
const processBtn = document.getElementById("processBtn");
const resultSection =
document.getElementById("resultSection");
const videoResultSection =
document.getElementById("videoResultSection");
processBtn.disabled = true;
isBatchProcessing = true;
resultSection.classList.add("hidden");
videoResultSection?.classList.add("hidden");
document.getElementById("progressFill").style.width = "0%";
const total = files.length;
let successCount = 0;
let failCount = 0;
const batchStartTime = Date.now();
for (let i = 0; i < total; i++) {
const file = files[i];
const tag = total > 1 ? `[${i + 1}/${total}] ` : "";
const fileStartTime = Date.now();
updateBatchItemStatus(i, "processing", "Processing");
// Update preview to show current file being processed
showVideoPreview(file);
let lastSeekTime = 0;
const previewVideo =
document.getElementById("videoPreviewPlayer");
upscaler.onProgress((progress) => {
document.getElementById(
"progressFill",
).style.width = progress + "%";
document.getElementById(
"progressText",
).textContent =
total > 1
? `${tag}${Math.round(progress)}% — ${truncateName(file.name)}`
: `Processing... ${Math.round(progress)}%`;
});
try {
const resultBlob = await upscaler.upscaleVideo(
file,
networkSize,
);
const saved = await writeToSaveDirectory(
file,
resultBlob,
);
updateBatchItemStatus(
i,
"done",
saved ? "Saved" : "Done",
);
successCount++;
// Add to right-side result list
const fileDuration = Date.now() - fileStartTime;
addBatchResult(file, resultBlob, i, fileDuration);
// Auto-load first result into comparison viewer
if (batchResults.length === 1) {
loadResultIntoComparison(0);
}
} catch (error) {
console.error(
`Upscaling error for ${file.name}:`,
error,
);
const errDetail = error.message
? error.message.substring(0, 120)
: String(error).substring(0, 120);
updateBatchItemStatus(
i,
"error",
"Failed",
errDetail,
);
failCount++;
}
}
const totalDuration = Date.now() - batchStartTime;
if (total > 1) {
document.getElementById(
"progressText",
).textContent = `${successCount} succeeded, ${failCount} failed · Total: ${formatDuration(totalDuration)}`;
if (failCount === 0) {
document.getElementById(
"progressFill",
).style.width = "100%";
}
} else if (successCount > 0) {
document.getElementById(
"progressText",
).textContent = `Done · ${formatDuration(totalDuration)}`;
} else {
document.getElementById(
"progressFill",
).style.width = "0%";
document.getElementById("progressText").textContent =
"Start upscaling";
}
if (failCount > 0) {
alert(
`${failCount} of ${total} video(s) failed to process.\n\n` +
`Hover over the "Failed" badges in the file list to see error details.\n` +
`Check the browser console (F12) for full error logs.`,
);
}
processBtn.disabled = false;
isBatchProcessing = false;
}
function downloadResult() {
if (!currentResultBlob) return alert("No result available.");
const a = document.createElement("a");
const downloadUrl = URL.createObjectURL(currentResultBlob);
a.href = downloadUrl;
const sel = batchResults[selectedResultIndex];
const origName = sel ? sel.file.name : "video";
const dot = origName.lastIndexOf(".");
const base = dot > 0 ? origName.slice(0, dot) : origName;
a.download = `${base}_upscaled.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke after a delay to ensure large file downloads start
setTimeout(() => URL.revokeObjectURL(downloadUrl), 10000);
}
function formatBytes(bytes) {
if (!bytes || bytes < 0) return "0 B";
const k = 1024,
sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(k)),
sizes.length - 1,
);
return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
}
function escapeHtml(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function formatDuration(ms) {
if (ms == null || isNaN(ms)) return "-";
if (ms < 1000) return ms + " ms";
const s = ms / 1000;
if (s < 60) return s.toFixed(1) + " s";
const m = Math.floor(s / 60);
const rest = (s - m * 60).toFixed(0);
return `${m}m ${rest}s`;
}
function resetUpscalerUI() {
// Cleanup comparison listeners
if (comparisonCleanup) {
comparisonCleanup();
comparisonCleanup = null;
}
document.getElementById("universalInput").value = "";
currentResultBlob = null;
currentVideoFiles = [];
if (currentOriginalUrl) URL.revokeObjectURL(currentOriginalUrl);
currentOriginalUrl = null;
// Revoke all batch result URLs
batchResults.forEach((r) => {
URL.revokeObjectURL(r.enhancedUrl);
URL.revokeObjectURL(r.originalUrl);
});
batchResults = [];
selectedResultIndex = -1;
batchFileStatuses = [];
document.getElementById("batchResultPanel")?.classList.add("hidden");
document.getElementById("comparisonDataPanel")?.classList.add("hidden");
document.getElementById("processBtn").disabled = true;
document.getElementById("progressFill").style.width = "0%";
document.getElementById("progressText").textContent =
"Select videos to start";
document.getElementById("durationVideoInfo").textContent = "-";
document
.getElementById("resultSection")
.classList.add("hidden");
document
.getElementById("videoResultSection")
?.classList.add("hidden");
["resultVideoEnhanced", "resultVideoOriginal"].forEach((id) => {
const v = document.getElementById(id);
if (v) {
v.pause();
v.removeAttribute("src");
v.load();
}
});
document
.getElementById("batchFileList")
?.classList.add("hidden");
resetVideoPreview();
}
// Shared: write a blob to the save directory
async function writeToSaveDirectory(file, blob) {
if (!saveDirectoryHandle) return false;
const dot = file.name.lastIndexOf(".");
const base = dot > 0 ? file.name.slice(0, dot) : file.name;
const outName = `${base}_upscaled.mp4`;
const fh = await saveDirectoryHandle.getFileHandle(outName, {
create: true,
});
const writable = await fh.createWritable();
await writable.write(blob);
await writable.close();
return true;
}
// Shared: truncate long filenames for UI display
function truncateName(name, maxLen = 30) {
if (!name) return "";
return name.length > maxLen
? name.substring(0, maxLen - 3) + "..."
: name;
}
console.log("Upscaler ready:", Upscaler.getBackendType());
// Comparison video controls
function toggleComparisonPlayPause(event) {
event.preventDefault();
event.stopPropagation();
const enhancedVideo = document.getElementById('resultVideoEnhanced');
const originalVideo = document.getElementById('resultVideoOriginal');
const playIcon = document.getElementById('comparisonPlayIcon');
const pauseIcon = document.getElementById('comparisonPauseIcon');
if (!enhancedVideo || !originalVideo) return;
if (enhancedVideo.paused) {
enhancedVideo.play();
originalVideo.play();
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
} else {
enhancedVideo.pause();
originalVideo.pause();
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
}
}
// Update play/pause icon when video state changes
(function initComparisonVideoEvents() {
const enhancedVideo = document.getElementById('resultVideoEnhanced');
const playIcon = document.getElementById('comparisonPlayIcon');
const pauseIcon = document.getElementById('comparisonPauseIcon');
if (!enhancedVideo || !playIcon || !pauseIcon) return;
enhancedVideo.addEventListener('play', () => {
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
});
enhancedVideo.addEventListener('pause', () => {
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
});
})();
</script>
</body>
</html>