From 1e0f78e83bd55dc2c44e5cb75b4a7cb2d081944a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:47:15 +0000 Subject: [PATCH] Add ShadowClip Web: browser-based clip editing, no install required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fully client-side editor under web/ — drop ShadowPlay recordings into the page and trim/export clips without installing anything. Files never leave the machine. - Frame-by-frame scrubbing forwards AND backwards: exact frame rate is parsed from the MP4 sample table (stts/mdhd) and steps are anchored to the presentation timestamp of the on-screen frame via requestVideoFrameCallback, so rapid stepping stays frame-exact. - Drag-on-video fine scrubbing (~1 ms/px, Shift for coarse), click to play/pause, keyboard shortcuts matching the desktop app. - Segments with per-segment speed (0.25x-4x) and center zoom (1x/2x/4x), timeline with thumbnail filmstrip and draggable segment edges; segments are concatenated on export like the desktop app. - In-browser export via ffmpeg.wasm using the same filter graph as the desktop FfmpegEncoder (trim/setpts, scale+crop zoom, setpts/atempo speed, concat, optional setdar 16:9), with stream-copy fast path under the same restrictions. Output is normalized to the source frame rate when speeds change, fixing timestamp mangling the CFR default causes. - Screenshot to clipboard (Safari-compatible promise ClipboardItem), audio presence probing for non-MP4 containers, HEVC/undecodable sources still exportable via parsed metadata, Infinity-duration WebM handling, and single-threaded ffmpeg core so no COOP/COEP headers are needed (hostable on GitHub Pages). - vendor/ffmpeg-worker.js: self-contained @ffmpeg/ffmpeg 0.12.10 module worker (MIT) so the engine loads from a CDN cross-origin. Verified end-to-end in headless Chromium with pixel-level frame checks (33 assertions: fps parsing incl. 59.94/moov-at-end, bidirectional and rapid stepping, segment ops, copy/re-encode/video-only/WebM exports decoded and checked with native ffmpeg). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NiZVridAzJ92rnDPXWh3PK --- README.md | 6 + web/README.md | 85 +++ web/css/style.css | 288 ++++++++++ web/index.html | 97 ++++ web/js/app.js | 1059 +++++++++++++++++++++++++++++++++++ web/js/exporter.js | 286 ++++++++++ web/js/mp4.js | 202 +++++++ web/vendor/ffmpeg-worker.js | 183 ++++++ 8 files changed, 2206 insertions(+) create mode 100644 web/README.md create mode 100644 web/css/style.css create mode 100644 web/index.html create mode 100644 web/js/app.js create mode 100644 web/js/exporter.js create mode 100644 web/js/mp4.js create mode 100644 web/vendor/ffmpeg-worker.js diff --git a/README.md b/README.md index 9d080d0..9c27a28 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # shadowclip Manage your shadow play videos. + +- **Desktop app** (`ShadowClip/`) — the original WPF application. +- **[ShadowClip Web](web/README.md)** (`web/`) — edit and create clips entirely + in the browser, no installation required: drag & drop your ShadowPlay files, + scrub frame by frame (forwards *and* backwards), trim segments with + speed/zoom, and export MP4s in-browser via ffmpeg.wasm. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..6b2aa73 --- /dev/null +++ b/web/README.md @@ -0,0 +1,85 @@ +# ShadowClip Web + +A browser-based version of ShadowClip: trim gameplay clips, step through them +frame by frame, and create shareable MP4s — **without installing anything**. +Everything runs client-side; the videos you drop in never leave your machine. + +## Running it + +It's a static site, so any static file server works: + +``` +cd web +python3 -m http.server 8000 # or: npx serve . +``` + +then open . It also works hosted on GitHub Pages or any +web host (no special headers required — the single-threaded ffmpeg.wasm core is +used deliberately so COOP/COEP isn't needed). + +> Opening `index.html` directly from disk (`file://`) won't work because the +> app uses ES modules — serve it over HTTP. + +## Features + +- **Drag & drop** one or more ShadowPlay recordings (or use *Open videos…*); + files are read locally via the File API, never uploaded. +- **Frame-by-frame scrubbing, forwards and backwards** (`←`/`→`, hold to + repeat). The exact frame rate is read from the MP4 sample table, and + stepping is anchored to the presentation timestamp of the frame actually on + screen (`requestVideoFrameCallback`), so steps land on real frame + boundaries in both directions. +- **Fine scrubbing** by dragging on the video (≈1 ms per pixel, hold `Shift` + for 10 ms per pixel), just like the desktop app. A click without dragging + toggles play/pause. +- **Segments** with per-segment **speed** (0.25×–4×) and **center zoom** + (1×/2×/4×), previewed live during playback. Segments are concatenated in + order when the clip is created — the same model as the desktop app. + The initial segment covers the last 30% of the clip, matching the desktop + default for ShadowPlay recordings. +- **Timeline** with a thumbnail filmstrip, draggable segment edges, and a + playhead. +- **In-browser export** via ffmpeg.wasm using the same filter graph as the + desktop app (`trim`/`setpts`, `scale`+`crop` zoom, `setpts`/`atempo` speed, + `concat`, optional `setdar=16/9`): + - *Re-encode* — frame-accurate cuts, x264 CRF 25 (slow in a browser; that's + the trade-off for not installing anything). + - *Stream copy* — near-instant, but cuts at keyframes and (as on desktop) + only for a single segment with no speed/zoom/16:9 changes. +- **Screenshot** of the current frame (respecting zoom) straight to the + clipboard, with download fallback. + +## Keyboard shortcuts + +| Key | Action | +| --- | --- | +| `Space` | Play / pause | +| `←` / `→` (or `,` / `.`) | Step one frame back / forward | +| `Shift+←` / `Shift+→` | Jump 1 second | +| `[` / `]` | Set start / end of the segment under the playhead | +| `Home` / `End` | Jump to clip start / end | +| `M` | Mute | +| `S` | Screenshot | + +## Notes & limitations + +- The ffmpeg engine (~31 MB) is fetched from a CDN (unpkg) the first time you + export and cached by the browser afterwards. To self-host it, download + `@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.js`, + `@ffmpeg/util@0.12.1/dist/umd/index.js`, and + `@ffmpeg/core@0.12.6/dist/esm/ffmpeg-core.js` + `ffmpeg-core.wasm`, then set + `window.SHADOWCLIP_FFMPEG_URLS = { ffmpegJs, utilJs, coreJs, coreWasm }` + before `js/app.js` loads. (The worker, `vendor/ffmpeg-worker.js`, is already + served locally by this app.) +- Browser encoding is single-threaded WebAssembly — expect re-encodes to be + much slower than the desktop app. Stream copy is fast. +- Very large recordings (≳1.5 GB) can exceed the WebAssembly memory limit + during export. +- HEVC/HDR captures may not *play* in browsers without HEVC support, though + export can still work since decoding happens in ffmpeg.wasm. +- Frame stepping uses `requestVideoFrameCallback` (all modern browsers); + without it the app falls back to fps-based stepping. +- For non-MP4 containers (WebM/MKV) the frame rate is estimated from + playback, which can't see rates above your display's refresh rate — a + 120 fps WebM on a 60 Hz screen steps 2 frames at a time. MP4s (including + all ShadowPlay recordings) use the exact rate from the file's metadata. diff --git a/web/css/style.css b/web/css/style.css new file mode 100644 index 0000000..894b006 --- /dev/null +++ b/web/css/style.css @@ -0,0 +1,288 @@ +:root { + --bg: #0e1014; + --bg-panel: #171a21; + --bg-raised: #1f232d; + --border: #2b3040; + --text: #d7dce6; + --text-dim: #8a92a6; + --accent: #4f8ef7; + --accent-2: #34c98e; + --danger: #e05555; +} + +* { box-sizing: border-box; } + +/* The hidden attribute must beat the display rules below. */ +[hidden] { display: none !important; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font: 14px/1.45 system-ui, "Segoe UI", sans-serif; +} + +body { display: flex; flex-direction: column; } + +button, select, input[type="text"], input[type="number"] { + background: var(--bg-raised); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 10px; + font: inherit; + cursor: pointer; +} +input[type="text"], input[type="number"] { cursor: text; } +button:hover:not(:disabled), select:hover { border-color: var(--accent); } +button:disabled { opacity: 0.45; cursor: default; } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } +button.primary:hover:not(:disabled) { filter: brightness(1.1); } + +.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; color: var(--text-dim); } +.hint { color: var(--text-dim); font-size: 12px; } +.spacer { flex: 1; } +.chk { display: inline-flex; align-items: center; gap: 5px; color: var(--text-dim); } + +header { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 16px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); +} +header h1 { margin: 0; font-size: 18px; letter-spacing: 0.5px; } +header h1 span { color: var(--accent); } +header h1 small { color: var(--text-dim); font-weight: 400; font-size: 12px; } +#mediaInfo { color: var(--text-dim); font-size: 12.5px; } + +main { flex: 1; display: flex; min-height: 0; } + +aside { + width: 230px; + background: var(--bg-panel); + border-right: 1px solid var(--border); + padding: 10px; + overflow-y: auto; + flex-shrink: 0; +} +aside h2, .panel-head h2 { margin: 0; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); } +#fileList { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } +#fileList li { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: "name remove" "meta remove"; + align-items: center; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; +} +#fileList li:hover { background: var(--bg-raised); } +#fileList li.active { background: var(--bg-raised); border-color: var(--accent); } +#fileList .fname { grid-area: name; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } +#fileList .fmeta { grid-area: meta; color: var(--text-dim); font-size: 11px; } +#fileList .fremove { grid-area: remove; background: none; border: none; color: var(--text-dim); padding: 4px 6px; } +#fileList .fremove:hover { color: var(--danger); } + +#editor { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + padding: 12px 16px; + gap: 10px; + overflow-y: auto; +} + +#videoWrap { + position: relative; + background: #000; + border-radius: 8px; + overflow: hidden; + aspect-ratio: 16 / 9; + max-height: 56vh; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +#video { width: 100%; height: 100%; transform-origin: center center; touch-action: none; } +#emptyHint { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-dim); + pointer-events: none; + text-align: center; +} +#emptyHint strong { font-size: 20px; color: var(--text); } +#playbackWarning { + position: absolute; + left: 12px; + right: 12px; + bottom: 12px; + background: rgba(224, 85, 85, 0.15); + border: 1px solid var(--danger); + border-radius: 6px; + padding: 8px 12px; + font-size: 12.5px; +} + +#transport { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +#transport input[type="range"] { width: 130px; } + +#timeline { + position: relative; + height: 56px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border); + cursor: crosshair; + flex-shrink: 0; + touch-action: none; + user-select: none; +} +#filmstrip { position: absolute; inset: 0; width: 100%; height: 100%; } +#segLayer { position: absolute; inset: 0; pointer-events: none; } +.seg-block { + position: absolute; + top: 0; + bottom: 0; + background: rgba(79, 142, 247, 0.22); + border: 1px solid rgba(79, 142, 247, 0.85); + border-radius: 4px; + pointer-events: none; +} +.seg-block.current { background: rgba(52, 201, 142, 0.25); border-color: var(--accent-2); } +.seg-label { + position: absolute; + top: 2px; + left: 10px; + font-size: 11px; + color: #fff; + text-shadow: 0 1px 2px #000; + white-space: nowrap; +} +.seg-handle { + position: absolute; + top: 0; + bottom: 0; + width: 10px; + pointer-events: auto; + cursor: ew-resize; + touch-action: none; +} +.seg-handle.start { left: -5px; } +.seg-handle.end { right: -5px; } +.seg-handle::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 4px; + width: 2px; + background: #fff; + opacity: 0.7; +} +#playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background: #ff4d4d; + pointer-events: none; + box-shadow: 0 0 4px rgba(255, 77, 77, 0.8); +} + +#segmentsPanel, #exportPanel { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + flex-shrink: 0; +} +.panel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; flex-wrap: wrap; } +#segRows { display: flex; flex-direction: column; gap: 6px; } +.seg-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.seg-row .seg-title { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--bg-raised); + border: 1px solid var(--border); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--text-dim); +} +.seg-row input[type="number"] { width: 96px; font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; } +.seg-row button { padding: 4px 8px; } + +.export-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +#outName { width: 240px; } +#exportProgressWrap { display: flex; align-items: center; gap: 10px; margin-top: 10px; } +#exportBarTrack { flex: 1; height: 8px; background: var(--bg-raised); border-radius: 4px; overflow: hidden; } +#exportBar { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width 0.2s; } +#exportLog { + margin: 8px 0 0; + max-height: 140px; + overflow: auto; + background: #000; + border-radius: 6px; + padding: 8px; + font-size: 11px; + color: #f0a0a0; + white-space: pre-wrap; +} +#exportResult { display: flex; align-items: center; gap: 14px; margin-top: 10px; flex-wrap: wrap; } +#exportResult a { color: var(--accent-2); font-weight: 600; } +#resultVideo { max-width: 320px; max-height: 180px; border-radius: 6px; background: #000; } + +#dropOverlay { + position: fixed; + inset: 0; + background: rgba(14, 16, 20, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + pointer-events: none; +} +#dropOverlay div { + padding: 30px 60px; + border: 3px dashed var(--accent); + border-radius: 16px; + font-size: 26px; + font-weight: 700; + color: var(--accent); +} + +#toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--bg-raised); + border: 1px solid var(--accent); + border-radius: 8px; + padding: 10px 18px; + opacity: 0; + transition: opacity 0.25s, transform 0.25s; + pointer-events: none; + z-index: 60; + max-width: 70vw; +} +#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } +#toast.error { border-color: var(--danger); color: #ffb3b3; } + +@media (max-width: 900px) { + aside { display: none; } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..baa49d7 --- /dev/null +++ b/web/index.html @@ -0,0 +1,97 @@ + + + + + + ShadowClip Web + + + +
+

ShadowClip web

+ + + + Drop ShadowPlay files anywhere — nothing is uploaded +
+ +
+ + +
+
+ +
+

Drop gameplay clips here

+

or use “Open videos…” — everything runs locally in your browser.

+
+ +
+ +
+ + + + 0:00.000 / 0:00.000 + + + + + +
+ +
+ +
+
+
+ +
+
+

Segments

+ + [ and ] set start/end of the segment under the playhead • segments are concatenated on export +
+
+
+ +
+

Create clip

+
+ + + + + +
+ + + + +

The first export downloads the ffmpeg engine (~31 MB) from a CDN. Your video never leaves this machine.

+
+
+
+ + +
+ + + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..81c563f --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,1059 @@ +// ShadowClip Web — browser-based gameplay clip editor. +// +// Everything runs client-side: files are opened via drag & drop or the file +// picker and never uploaded. Playback uses the HTML5 video element; frame +// stepping combines the exact fps from the MP4 sample table (mp4.js) with +// requestVideoFrameCallback so steps land on real frame boundaries in both +// directions. Export runs ffmpeg.wasm (exporter.js). + +import { parseMp4Info } from './mp4.js'; +import { buildExportArgs, copyModeBlockers, outputDuration, exportClip, cancelExport } from './exporter.js'; + +const $ = id => document.getElementById(id); + +const video = $('video'); +const videoWrap = $('videoWrap'); +const dropOverlay = $('dropOverlay'); +const emptyHint = $('emptyHint'); +const playbackWarning = $('playbackWarning'); +const fileListEl = $('fileList'); +const filmstrip = $('filmstrip'); +const timelineEl = $('timeline'); +const segLayer = $('segLayer'); +const playheadEl = $('playhead'); +const segRowsEl = $('segRows'); +const timeText = $('timeText'); +const frameText = $('frameText'); +const mediaInfoText = $('mediaInfo'); +const toastEl = $('toast'); + +const SPEED_PRESETS = [0.25, 0.5, 1, 2, 4]; +const ZOOM_PRESETS = [1, 2, 4]; +const COMMON_RATES = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 75, 90, 100, 120, 144, 165, 240]; +const FALLBACK_FPS = 60; + +const state = { + files: [], // {id, file, url, info, segments, error} + current: null, // entry from files + segments: [], // segments of the current file + duration: 0, + fps: null, + fpsSource: null, // 'metadata' | 'estimated' + lastMediaTime: null, // presentation time of the frame on screen (rVFC) + stepTarget: null, // absolute time of an in-flight frame step + previewSegment: null, // segment being previewed (auto-pause at its end) + pendingSeek: null, // latest scrub target while a seek is in flight + exporting: false, + fpsSamples: [], + settings: loadSettings(), +}; +let nextId = 1; +let filmstripToken = null; +let lastResultUrl = null; + +// Debug/test hook. +window.__shadowclip = { state, video, stepFrame, segmentAt, requestSeek, renderSegRows, makeSegment }; + +// ---------------------------------------------------------------- settings + +function loadSettings() { + const defaults = { volume: 1, muted: false, encoder: 're-encode', forceWideScreen: false }; + try { + return Object.assign(defaults, JSON.parse(localStorage.getItem('shadowclip.settings') || '{}')); + } catch { + return defaults; + } +} + +function saveSettings() { + try { + localStorage.setItem('shadowclip.settings', JSON.stringify(state.settings)); + } catch { + // storage unavailable (private mode) — settings just won't persist + } +} + +// ------------------------------------------------------------------- utils + +function clamp(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); +} + +function frameDur() { + return 1 / (state.fps || FALLBACK_FPS); +} + +function fmtTime(t) { + if (!Number.isFinite(t)) t = 0; + const m = Math.floor(t / 60); + const s = t - m * 60; + return `${m}:${s.toFixed(3).padStart(6, '0')}`; +} + +function toast(message, isError = false) { + toastEl.textContent = message; + toastEl.className = 'show' + (isError ? ' error' : ''); + clearTimeout(toastEl._timer); + toastEl._timer = setTimeout(() => (toastEl.className = ''), 4000); +} + +function safeFileName(name) { + // Leading dashes must go too — the name becomes an ffmpeg CLI argument. + return name.replace(/[\\/:*?"<>|\s]+/g, '_').replace(/^[-_]+|_+$/g, '') || 'clip'; +} + +function fileStem(name) { + return name.replace(/\.[^.]+$/, ''); +} + +// ------------------------------------------------------------ file loading + +function addFiles(list) { + const added = []; + for (const file of list) { + const looksVideo = /^video\//.test(file.type) || /\.(mp4|m4v|mov|mkv|webm|avi)$/i.test(file.name); + if (!looksVideo) continue; + const entry = { id: nextId++, file, url: URL.createObjectURL(file), info: null, segments: null, error: null }; + state.files.push(entry); + added.push(entry); + parseMp4Info(file).then(info => { + entry.info = info; + if (entry === state.current) { + applyMediaInfo(); + applyParsedDurationFallback(); + } + renderFileList(); + }); + } + if (!added.length) { + toast('No video files found in the drop', true); + return; + } + renderFileList(); + if (!state.current) selectFile(added[0]); +} + +function selectFile(entry) { + if (state.current === entry) return; + if (state.current) state.current.segments = state.segments; // stash edits + state.current = entry; + state.duration = 0; + state.fps = null; + state.fpsSource = null; + state.fpsSamples = []; + state.lastMediaTime = null; + state.stepTarget = null; + state.pendingSeek = null; + state.previewSegment = null; + state.segments = entry.segments || []; + entry.error = null; + playbackWarning.hidden = true; + emptyHint.hidden = true; + video.src = entry.url; + video.load(); + $('outName').value = safeFileName(fileStem(entry.file.name)) + '_clip'; + applyMediaInfo(); + renderFileList(); + renderSegRows(); + syncSegmentUI(); +} + +function removeFile(entry) { + const idx = state.files.indexOf(entry); + if (idx === -1) return; + state.files.splice(idx, 1); + URL.revokeObjectURL(entry.url); + if (state.current === entry) { + if (filmstripToken) filmstripToken.cancelled = true; + state.current = null; + state.segments = []; + state.duration = 0; + video.removeAttribute('src'); + video.load(); + if (state.files.length) { + selectFile(state.files[Math.min(idx, state.files.length - 1)]); + } else { + emptyHint.hidden = false; + applyMediaInfo(); + renderSegRows(); + syncSegmentUI(); + drawFilmstripPlaceholder(); + } + } + renderFileList(); +} + +function renderFileList() { + fileListEl.textContent = ''; + for (const entry of state.files) { + const li = document.createElement('li'); + li.className = entry === state.current ? 'active' : ''; + const name = document.createElement('span'); + name.className = 'fname'; + name.textContent = entry.file.name; + name.title = entry.file.name; + const meta = document.createElement('span'); + meta.className = 'fmeta'; + const mb = (entry.file.size / 1048576).toFixed(1) + ' MB'; + const fps = entry.info?.fps ? ` • ${roundFps(entry.info.fps)} fps` : ''; + meta.textContent = mb + fps; + const remove = document.createElement('button'); + remove.className = 'fremove'; + remove.textContent = '✕'; + remove.title = 'Remove from list'; + remove.addEventListener('click', e => { + e.stopPropagation(); + removeFile(entry); + }); + li.append(name, meta, remove); + li.addEventListener('click', () => selectFile(entry)); + fileListEl.appendChild(li); + } + $('fileCount').textContent = state.files.length ? `(${state.files.length})` : ''; +} + +function roundFps(fps) { + return Math.abs(fps - Math.round(fps)) < 0.002 ? String(Math.round(fps)) : fps.toFixed(3); +} + +function applyMediaInfo() { + const entry = state.current; + if (!entry) { + mediaInfoText.textContent = ''; + return; + } + if (entry.info?.fps) { + state.fps = entry.info.fps; + state.fpsSource = 'metadata'; + } + const parts = []; + if (video.videoWidth) parts.push(`${video.videoWidth}×${video.videoHeight}`); + if (state.fps) + parts.push(`${roundFps(state.fps)} fps${state.fpsSource === 'estimated' ? ' (estimated)' : ''}`); + if (entry.info?.frameCount) parts.push(`${entry.info.frameCount} frames`); + mediaInfoText.textContent = parts.join(' • '); +} + +// -------------------------------------------------------------- player core + +video.addEventListener('loadedmetadata', () => { + if (!Number.isFinite(video.duration)) { + // MediaRecorder-produced WebMs report Infinity until the browser is + // forced to scan the file; seeking far past the end resolves it. + const entry = state.current; + const onDur = () => { + if (!Number.isFinite(video.duration)) return; + video.removeEventListener('durationchange', onDur); + if (state.current !== entry) return; + video.currentTime = 0; + initFromMetadata(); + }; + video.addEventListener('durationchange', onDur); + video.currentTime = 1e10; + return; + } + initFromMetadata(); +}); + +function initFromMetadata() { + state.duration = video.duration; + if (!state.segments.length) { + // Desktop default: ShadowPlay's interesting bit is at the end, so the + // initial segment covers the last 30% of the clip. + state.segments = [makeSegment(state.duration * 0.7, state.duration)]; + } else { + for (const s of state.segments) { + s.start = clamp(s.start, 0, state.duration); + s.end = clamp(s.end, s.start, state.duration); + } + } + if (state.current) state.current.segments = state.segments; + applyMediaInfo(); + renderSegRows(); + syncSegmentUI(); + updateTimeUI(); + buildFilmstrip(); +} + +video.addEventListener('error', () => { + if (!state.current) return; + state.current.error = 'playback'; + playbackWarning.hidden = false; + applyParsedDurationFallback(); +}); + +// The browser can't decode the source, but if the MP4 parser got a duration +// the segments and export still work (decoding happens in ffmpeg.wasm) — +// there's just no preview. Runs from both the video error handler and the +// async parse completion, whichever comes second. +function applyParsedDurationFallback() { + const entry = state.current; + if (!entry || entry.error !== 'playback' || state.duration) return; + const dur = entry.info?.videoDuration; + if (!dur || !Number.isFinite(dur)) return; + state.duration = dur; + if (!state.segments.length) state.segments = [makeSegment(dur * 0.7, dur)]; + entry.segments = state.segments; + applyMediaInfo(); + renderSegRows(); + syncSegmentUI(); + updateTimeUI(); +} + +video.addEventListener('play', () => syncPlayButton()); +// Swapping src while playing pauses without a 'pause' event. +video.addEventListener('emptied', () => syncPlayButton()); +video.addEventListener('pause', () => { + state.previewSegment = null; + syncPlayButton(); +}); +video.addEventListener('seeked', () => { + if (state.pendingSeek !== null) { + const target = state.pendingSeek; + state.pendingSeek = null; + if (Math.abs(target - video.currentTime) > frameDur() / 2) { + video.currentTime = target; + return; + } + } + updateTimeUI(); +}); + +function syncPlayButton() { + $('btnPlay').textContent = video.paused ? '▶' : '⏸'; + $('btnPlay').title = video.paused ? 'Play (Space)' : 'Pause (Space)'; +} + +function togglePlay() { + if (!state.current) return; + if (video.paused) { + state.stepTarget = null; + video.play().catch(() => {}); + } else { + video.pause(); + } +} + +// Latest presented frame drives the UI: mediaTime is the exact presentation +// timestamp of the frame on screen, which is what frame stepping chains from. +function onVideoFrame(_now, meta) { + if (state.fpsSource !== 'metadata') estimateFps(meta); + if (!video.seeking) state.stepTarget = null; + state.lastMediaTime = meta.mediaTime; + updateTimeUI(meta.mediaTime); + video.requestVideoFrameCallback(onVideoFrame); +} +if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(onVideoFrame); + +// Fps estimation fallback for files the MP4 parser can't read (e.g. webm): +// trimmed mean of mediaTime deltas between presented frames during 1x +// playback (webm timestamps are millisecond-quantized, so a plain median +// lands on 1/0.017 = 58.8 for 60 fps sources), snapped to the nearest +// common rate. Seeks reset sampling so scrub jumps never pollute it. +let prevSampleTime = null; +video.addEventListener('seeking', () => (prevSampleTime = null)); +function estimateFps(meta) { + if (video.paused || video.seeking || video.playbackRate !== 1) { + prevSampleTime = null; + return; + } + if (prevSampleTime !== null) { + const delta = meta.mediaTime - prevSampleTime; + if (delta > 0.001 && delta < 0.5) { + state.fpsSamples.push(delta); + if (state.fpsSamples.length > 120) state.fpsSamples.shift(); + } + } + prevSampleTime = meta.mediaTime; + if (state.fpsSamples.length >= 20) { + const sorted = [...state.fpsSamples].sort((a, b) => a - b); + const trim = Math.floor(sorted.length / 5); + const kept = sorted.slice(trim, sorted.length - trim); + const mean = kept.reduce((a, b) => a + b, 0) / kept.length; + let fps = 1 / mean; + let best = null; + let bestErr = Infinity; + for (const rate of COMMON_RATES) { + const err = Math.abs(fps - rate) / rate; + if (err < 0.025 && err < bestErr) { + best = rate; + bestErr = err; + } + } + if (best !== null) fps = best; + state.fps = fps; + state.fpsSource = 'estimated'; + applyMediaInfo(); + } +} + +// Frame stepping. The frame on screen occupies [mediaTime, mediaTime + d). +// Seeking to the *middle* of the neighbouring frame's interval reliably +// lands on that frame in every browser, and chaining pending steps off +// stepTarget keeps rapid key-repeat accurate even while seeks are in flight. +function stepFrame(dir) { + if (!state.current || !state.duration) return; + if (!video.paused) video.pause(); + state.previewSegment = null; + const d = frameDur(); + let base; + if (state.stepTarget !== null) base = state.stepTarget; + else if (state.pendingSeek !== null) base = state.pendingSeek; + // While a scrub seek is in flight, lastMediaTime still describes the old + // frame — chain from the seek target (currentTime) instead. + else if (video.seeking) base = video.currentTime; + else if (state.lastMediaTime !== null && Math.abs(state.lastMediaTime - video.currentTime) < 1) + base = state.lastMediaTime + d / 2; + else base = video.currentTime; + const target = clamp(base + dir * d, 0, Math.max(0, state.duration - d / 4)); + state.stepTarget = target; + state.pendingSeek = null; + video.currentTime = target; + updateTimeUI(); +} + +// Scrub-style seek: absorbs bursts of requests by only issuing a new seek +// once the previous one lands (see the 'seeked' handler). +function requestSeek(t) { + if (!state.duration) return; + state.stepTarget = null; + state.previewSegment = null; + const target = clamp(t, 0, state.duration); + if (!Number.isFinite(target)) return; + if (video.seeking) { + state.pendingSeek = target; + } else { + state.pendingSeek = null; + video.currentTime = target; + } + updateTimeUI(target); +} + +function segmentAt(t) { + return state.segments.find(s => t >= s.start && t < s.end) || null; +} + +function currentFrameIndex(t) { + return Math.max(0, Math.round(t / frameDur())); +} + +// Single place that refreshes playhead, readouts, zoom preview, per-segment +// playback speed, and the segment-preview auto-pause. +function updateTimeUI(mediaTime) { + const t = mediaTime !== undefined ? mediaTime : video.currentTime; + timeText.textContent = `${fmtTime(t)} / ${fmtTime(state.duration)}`; + const total = state.current?.info?.frameCount; + frameText.textContent = state.fps + ? `frame ${currentFrameIndex(t)}${total ? ' / ' + total : ''}` + : ''; + playheadEl.style.left = state.duration ? (t / state.duration) * 100 + '%' : '0%'; + + const seg = state.previewSegment || segmentAt(t); + const zoom = seg ? seg.zoom : 1; + video.style.transform = zoom > 1 ? `scale(${zoom})` : ''; + + if (!video.paused) { + const wantRate = state.previewSegment ? state.previewSegment.speed : (seg ? seg.speed : 1); + if (video.playbackRate !== wantRate) video.playbackRate = wantRate; + if (state.previewSegment && t >= state.previewSegment.end) video.pause(); + } + + for (const el of segLayer.children) { + const s = state.segments[Number(el.dataset.index)]; + el.classList.toggle('current', !!s && t >= s.start && t < s.end); + } +} + +// Smooth playhead while playing (rVFC only fires on new frames, and not at +// all in browsers without it). +(function rafLoop() { + if (!video.paused && !video.ended) updateTimeUI(); + requestAnimationFrame(rafLoop); +})(); + +// Click video = play/pause; drag = fine scrub (desktop: 1 px ≈ 1 ms, shift +// for 10 ms/px). +let vidDrag = null; +video.addEventListener('pointerdown', e => { + if (!state.current || e.button !== 0) return; + vidDrag = { startX: e.clientX, lastX: e.clientX, moved: false, wasPlaying: !video.paused }; + video.setPointerCapture(e.pointerId); +}); +video.addEventListener('pointermove', e => { + if (!vidDrag) return; + const dx = e.clientX - vidDrag.lastX; + if (Math.abs(e.clientX - vidDrag.startX) > 3 && !vidDrag.moved) { + vidDrag.moved = true; + if (vidDrag.wasPlaying) video.pause(); + } + if (vidDrag.moved && dx !== 0) { + vidDrag.lastX = e.clientX; + const scale = e.shiftKey ? 0.01 : 0.001; + requestSeek((state.pendingSeek ?? video.currentTime) + dx * scale); + } +}); +video.addEventListener('pointerup', e => { + if (!vidDrag) return; + const { moved, wasPlaying } = vidDrag; + vidDrag = null; + if (!moved) togglePlay(); + else if (wasPlaying) video.play().catch(() => {}); +}); +video.addEventListener('pointercancel', () => { + if (vidDrag?.moved && vidDrag.wasPlaying) video.play().catch(() => {}); + vidDrag = null; +}); + +// ----------------------------------------------------------------- segments + +function makeSegment(start, end) { + return { id: nextId++, start, end, speed: 1, zoom: 1 }; +} + +function minSegLen() { + return Math.max(frameDur(), 0.001); +} + +// Desktop-style add: split the segment under the playhead, otherwise start a +// fresh segment at the playhead. +function addSegment() { + if (!state.duration) return; + const t = video.currentTime; + const seg = segmentAt(t); + if (seg && t - seg.start >= minSegLen() && seg.end - t >= minSegLen()) { + const tail = makeSegment(t, seg.end); + tail.speed = seg.speed; + tail.zoom = seg.zoom; + seg.end = t; + state.segments.splice(state.segments.indexOf(seg) + 1, 0, tail); + } else { + const end = Math.min(t + Math.max(5, state.duration * 0.05), state.duration); + if (end - t < minSegLen()) { + toast('Playhead is at the very end — nowhere to add a segment', true); + return; + } + state.segments.push(makeSegment(t, end)); + } + renderSegRows(); + syncSegmentUI(); +} + +function removeSegment(seg) { + if (state.segments.length <= 1) return; + state.segments.splice(state.segments.indexOf(seg), 1); + renderSegRows(); + syncSegmentUI(); +} + +function setSegEdge(seg, side, t, seekPreview = true) { + if (side === 'start') seg.start = clamp(t, 0, seg.end - minSegLen()); + else seg.end = clamp(t, seg.start + minSegLen(), state.duration); + if (seekPreview) requestSeek(side === 'start' ? seg.start : seg.end); + syncSegmentUI(); +} + +function renderSegRows() { + segRowsEl.textContent = ''; + segLayer.textContent = ''; + state.segments.forEach((seg, i) => { + // Timeline block with drag handles. + const block = document.createElement('div'); + block.className = 'seg-block'; + block.dataset.index = i; + const label = document.createElement('span'); + label.className = 'seg-label'; + block.appendChild(label); + for (const side of ['start', 'end']) { + const handle = document.createElement('div'); + handle.className = `seg-handle ${side}`; + handle.title = `Drag to set segment ${i + 1} ${side}`; + handle.addEventListener('pointerdown', e => beginHandleDrag(e, seg, side)); + block.appendChild(handle); + } + segLayer.appendChild(block); + + // Editor row. + const row = document.createElement('div'); + row.className = 'seg-row'; + row.dataset.index = i; + + const title = document.createElement('span'); + title.className = 'seg-title'; + title.textContent = String(i + 1); + + const startInput = numInput(() => seg.start, v => setSegEdge(seg, 'start', v)); + const endInput = numInput(() => seg.end, v => setSegEdge(seg, 'end', v)); + const markIn = smallBtn('⇤', 'Set start to playhead ( [ )', () => setSegEdge(seg, 'start', video.currentTime, false)); + const markOut = smallBtn('⇥', 'Set end to playhead ( ] )', () => setSegEdge(seg, 'end', video.currentTime, false)); + + const speedSel = presetSelect(SPEED_PRESETS, seg.speed, v => { + seg.speed = v; + syncSegmentUI(); + }, v => v + '×'); + const zoomSel = presetSelect(ZOOM_PRESETS, seg.zoom, v => { + seg.zoom = v; + syncSegmentUI(); + }, v => v + '× zoom'); + + const preview = smallBtn('▶', 'Preview this segment', () => previewSegment(seg)); + const del = smallBtn('✕', 'Delete segment', () => removeSegment(seg)); + del.disabled = state.segments.length <= 1; + + row.append(title, markIn, startInput, document.createTextNode('→'), endInput, markOut, speedSel, zoomSel, preview, del); + segRowsEl.appendChild(row); + }); + syncSegmentUI(); +} + +function numInput(get, set) { + const input = document.createElement('input'); + input.type = 'number'; + input.step = '0.001'; + input.min = '0'; + input.value = get().toFixed(3); + input.addEventListener('change', () => { + const v = parseFloat(input.value); + if (Number.isFinite(v)) set(v); + input.value = get().toFixed(3); + }); + input._sync = () => { + if (document.activeElement !== input) input.value = get().toFixed(3); + }; + return input; +} + +function smallBtn(text, title, onClick) { + const b = document.createElement('button'); + b.textContent = text; + b.title = title; + b.addEventListener('click', onClick); + return b; +} + +function presetSelect(presets, value, set, labelFor) { + const sel = document.createElement('select'); + for (const p of presets) { + const opt = document.createElement('option'); + opt.value = String(p); + opt.textContent = labelFor(p); + if (p === value) opt.selected = true; + sel.appendChild(opt); + } + sel.addEventListener('change', () => set(parseFloat(sel.value))); + return sel; +} + +// Refresh positions/labels without rebuilding the DOM (keeps focus + drags). +function syncSegmentUI() { + const dur = state.duration || 1; + state.segments.forEach((seg, i) => { + const block = segLayer.children[i]; + if (block) { + block.style.left = (seg.start / dur) * 100 + '%'; + block.style.width = (Math.max(seg.end - seg.start, 0) / dur) * 100 + '%'; + const bits = [String(i + 1)]; + if (seg.speed !== 1) bits.push(seg.speed + '×'); + if (seg.zoom !== 1) bits.push(seg.zoom + '×🔍'); + block.querySelector('.seg-label').textContent = bits.join(' '); + } + const row = segRowsEl.children[i]; + if (row) for (const input of row.querySelectorAll('input')) input._sync(); + }); + updateExportPanel(); +} + +function previewSegment(seg) { + if (!state.duration) return; + state.stepTarget = null; + state.pendingSeek = null; + video.currentTime = seg.start; + state.previewSegment = seg; + video.playbackRate = seg.speed; + video.play().catch(() => {}); +} + +// ----------------------------------------------------------------- timeline + +function beginHandleDrag(e, seg, side) { + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + const handle = e.target; + handle.setPointerCapture(e.pointerId); + const move = ev => setSegEdge(seg, side, xToTime(ev.clientX)); + const up = () => { + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', up); + handle.removeEventListener('pointercancel', up); + }; + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', up); + handle.addEventListener('pointercancel', up); + setSegEdge(seg, side, xToTime(e.clientX)); +} + +function xToTime(clientX) { + const rect = timelineEl.getBoundingClientRect(); + return clamp(((clientX - rect.left) / rect.width) * state.duration, 0, state.duration); +} + +let tlDrag = false; +timelineEl.addEventListener('pointerdown', e => { + if (e.button !== 0 || !state.duration) return; + if (e.target.classList.contains('seg-handle')) return; + tlDrag = true; + timelineEl.setPointerCapture(e.pointerId); + requestSeek(xToTime(e.clientX)); +}); +timelineEl.addEventListener('pointermove', e => { + if (tlDrag) requestSeek(xToTime(e.clientX)); +}); +timelineEl.addEventListener('pointerup', () => (tlDrag = false)); +timelineEl.addEventListener('pointercancel', () => (tlDrag = false)); + +// ---------------------------------------------------------------- filmstrip + +function drawFilmstripPlaceholder() { + const ctx = filmstrip.getContext('2d'); + ctx.fillStyle = '#14161c'; + ctx.fillRect(0, 0, filmstrip.width, filmstrip.height); +} + +async function buildFilmstrip() { + if (!state.current || !state.duration) return; + if (filmstripToken) filmstripToken.cancelled = true; + const token = { cancelled: false }; + filmstripToken = token; + + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const cssWidth = timelineEl.clientWidth || 800; + const cssHeight = timelineEl.clientHeight || 56; + filmstrip.width = Math.round(cssWidth * dpr); + filmstrip.height = Math.round(cssHeight * dpr); + drawFilmstripPlaceholder(); + + const thumb = document.createElement('video'); + thumb.muted = true; + thumb.preload = 'auto'; + thumb.src = state.current.url; + try { + try { + await eventOnce(thumb, 'loadedmetadata', 8000); + } catch { + return; + } + if (token.cancelled || !thumb.videoWidth) return; + + const h = filmstrip.height; + const w = Math.max(24, Math.round((thumb.videoWidth / thumb.videoHeight) * h)); + const count = Math.ceil(filmstrip.width / w); + const ctx = filmstrip.getContext('2d'); + for (let i = 0; i < count; i++) { + if (token.cancelled) return; + thumb.currentTime = clamp(((i + 0.5) / count) * state.duration, 0, state.duration); + try { + await eventOnce(thumb, 'seeked', 4000); + } catch { + break; + } + if (token.cancelled) return; + ctx.drawImage(thumb, i * w, 0, w, h); + } + } finally { + // Every exit path must release the media pipeline — detached video + // elements with a live src can pin decoders until GC gets around + // to them. + thumb.removeAttribute('src'); + thumb.load(); + } +} + +function eventOnce(el, name, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + el.removeEventListener(name, onEvent); + reject(new Error('timeout')); + }, timeoutMs); + const onEvent = () => { + clearTimeout(timer); + resolve(); + }; + el.addEventListener(name, onEvent, { once: true }); + }); +} + +let resizeTimer = null; +window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + buildFilmstrip(); + syncSegmentUI(); + }, 300); +}); + +// --------------------------------------------------------------- screenshot + +async function takeScreenshot() { + if (!state.current || !video.videoWidth) return; + const seg = segmentAt(video.currentTime); + const zoom = seg ? seg.zoom : 1; + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + const sw = video.videoWidth / zoom; + const sh = video.videoHeight / zoom; + ctx.drawImage(video, (video.videoWidth - sw) / 2, (video.videoHeight - sh) / 2, sw, sh, 0, 0, canvas.width, canvas.height); + // clipboard.write must be called synchronously within the user gesture + // (Safari invalidates activation across the toBlob await), so hand the + // ClipboardItem a promise instead of a resolved blob. + const blobPromise = new Promise((resolve, reject) => + canvas.toBlob(b => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')); + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blobPromise })]); + toast('Screenshot copied to clipboard'); + } catch { + const blob = await blobPromise.catch(() => null); + if (!blob) { + toast('Screenshot failed', true); + return; + } + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${safeFileName(fileStem(state.current.file.name))}_frame${currentFrameIndex(video.currentTime)}.png`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 10000); + toast('Clipboard unavailable — screenshot downloaded instead'); + } +} + +// ------------------------------------------------------------------- export + +// Source frame rate as an exact rational for the export filter graph. +// Metadata only: runtime estimates are capped at the display refresh rate +// (requestVideoFrameCallback fires per composited frame), so normalizing a +// high-fps source to an estimate would silently drop frames. +function fpsRational() { + const info = state.current?.info; + if (info?.fpsNum) return { num: info.fpsNum, den: info.fpsDen }; + return null; +} + +function updateExportPanel() { + const mode = $('encoderSel').value; + const blockers = copyModeBlockers(state.segments, $('forceWide').checked); + const note = $('copyNote'); + if (mode === 'copy') { + if (blockers.length) { + note.textContent = 'Stream copy unavailable with: ' + blockers.join(', ') + '.'; + note.hidden = false; + } else { + note.textContent = 'Stream copy cuts at keyframes — the clip may start slightly before your mark.'; + note.hidden = false; + } + } else { + note.hidden = true; + } + $('btnExport').disabled = state.exporting || !state.current || !state.segments.length || + (mode === 'copy' && blockers.length > 0); + if (state.segments.length && state.duration) + $('exportEstimate').textContent = '≈ ' + outputDuration(state.segments, mode).toFixed(2) + 's output'; + else $('exportEstimate').textContent = ''; +} + +async function startExport() { + if (state.exporting || !state.current) return; + const mode = $('encoderSel').value; + const forceWideScreen = $('forceWide').checked; + const blockers = copyModeBlockers(state.segments, forceWideScreen); + if (mode === 'copy' && blockers.length) return; + if (state.segments.some(s => s.end - s.start <= 0)) { + toast('A segment has zero length', true); + return; + } + if (state.current.file.size > 1.5 * 1024 ** 3) + toast('Heads up: files this large can exceed the browser ffmpeg memory limit', true); + + const outputName = safeFileName($('outName').value || 'clip') + '.mp4'; + state.exporting = true; + $('btnCancelExport').hidden = false; + $('exportProgressWrap').hidden = false; + $('exportResult').hidden = true; + $('exportLog').hidden = true; + $('exportLog').textContent = ''; + setExportProgress(0, 'Starting…'); + updateExportPanel(); + + try { + const blob = await exportClip({ + file: state.current.file, + segments: state.segments.map(s => ({ start: s.start, end: s.end, speed: s.speed, zoom: s.zoom })), + hasAudio: state.current.info ? state.current.info.hasAudio : null, + mode, + forceWideScreen, + outputName, + fps: fpsRational(), + onStatus: msg => setExportProgress(null, msg), + onProgress: p => setExportProgress(p, (p * 100).toFixed(0) + '%'), + }); + if (lastResultUrl) URL.revokeObjectURL(lastResultUrl); + lastResultUrl = URL.createObjectURL(blob); + const link = $('resultLink'); + link.href = lastResultUrl; + link.download = outputName; + link.textContent = `Save ${outputName} (${(blob.size / 1048576).toFixed(1)} MB)`; + $('resultVideo').src = lastResultUrl; + $('exportResult').hidden = false; + setExportProgress(1, 'Done'); + link.click(); // auto-download; the link stays for re-saving + toast('Clip created'); + } catch (err) { + const cancelled = err?.canceled || /terminate|not loaded/i.test(String(err?.message)); + setExportProgress(0, cancelled ? 'Canceled' : 'Failed'); + if (!cancelled) { + console.error(err); + toast('Export failed: ' + err.message.split('\n')[0], true); + $('exportLog').textContent = String(err.message); + $('exportLog').hidden = false; + } + } finally { + state.exporting = false; + $('btnCancelExport').hidden = true; + updateExportPanel(); + } +} + +function setExportProgress(fraction, label) { + if (fraction !== null) $('exportBar').style.width = (fraction * 100).toFixed(1) + '%'; + if (label) $('exportStatus').textContent = label; +} + +// ---------------------------------------------------------------- keyboard + +window.addEventListener('keydown', e => { + const tag = e.target.tagName; + if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' || tag === 'VIDEO' || e.isComposing) return; + // Leave browser/OS shortcuts (Ctrl+S, Cmd+arrows, …) alone. + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (!state.current) return; + switch (e.key) { + case ' ': + togglePlay(); + e.preventDefault(); + break; + case 'ArrowRight': + case '.': + if (e.shiftKey) requestSeek(video.currentTime + 1); + else stepFrame(1); + e.preventDefault(); + break; + case 'ArrowLeft': + case ',': + if (e.shiftKey) requestSeek(video.currentTime - 1); + else stepFrame(-1); + e.preventDefault(); + break; + case '[': { + const seg = segmentAt(video.currentTime) || state.segments[0]; + if (seg) setSegEdge(seg, 'start', video.currentTime, false); + break; + } + case ']': { + const seg = segmentAt(video.currentTime) || state.segments[state.segments.length - 1]; + if (seg) setSegEdge(seg, 'end', video.currentTime, false); + break; + } + case 'Home': + requestSeek(0); + e.preventDefault(); + break; + case 'End': + requestSeek(state.duration); + e.preventDefault(); + break; + case 'm': + $('muteChk').checked = !$('muteChk').checked; + $('muteChk').dispatchEvent(new Event('change')); + break; + case 's': + takeScreenshot(); + break; + } +}); + +// ------------------------------------------------------------------- wiring + +$('btnPlay').addEventListener('click', togglePlay); +$('btnPrevFrame').addEventListener('click', () => stepFrame(-1)); +$('btnNextFrame').addEventListener('click', () => stepFrame(1)); +$('btnAddSegment').addEventListener('click', addSegment); +$('btnScreenshot').addEventListener('click', takeScreenshot); +$('btnExport').addEventListener('click', startExport); +$('btnCancelExport').addEventListener('click', cancelExport); +$('encoderSel').addEventListener('change', () => { + state.settings.encoder = $('encoderSel').value; + saveSettings(); + updateExportPanel(); +}); +$('forceWide').addEventListener('change', () => { + state.settings.forceWideScreen = $('forceWide').checked; + saveSettings(); + updateExportPanel(); +}); +$('volume').addEventListener('input', () => { + video.volume = parseFloat($('volume').value); + state.settings.volume = video.volume; + saveSettings(); +}); +$('muteChk').addEventListener('change', () => { + video.muted = $('muteChk').checked; + state.settings.muted = video.muted; + saveSettings(); +}); + +$('btnOpen').addEventListener('click', () => $('filePicker').click()); +$('filePicker').addEventListener('change', e => { + addFiles([...e.target.files]); + e.target.value = ''; +}); + +// Only react to external file drags — in-page drags (text selections, the +// result link) must keep their native behavior and not trigger the overlay. +const isFileDrag = e => e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files'); +let dragDepth = 0; +window.addEventListener('dragenter', e => { + if (!isFileDrag(e)) return; + e.preventDefault(); + dragDepth++; + dropOverlay.hidden = false; +}); +window.addEventListener('dragleave', e => { + if (!isFileDrag(e)) return; + e.preventDefault(); + if (--dragDepth <= 0) { + dragDepth = 0; + dropOverlay.hidden = true; + } +}); +window.addEventListener('dragover', e => { + if (isFileDrag(e)) e.preventDefault(); +}); +window.addEventListener('drop', e => { + if (!isFileDrag(e)) return; + e.preventDefault(); + dragDepth = 0; + dropOverlay.hidden = true; + if (e.dataTransfer?.files?.length) addFiles([...e.dataTransfer.files]); +}); + +// Apply persisted settings. +video.volume = state.settings.volume; +video.muted = state.settings.muted; +$('volume').value = String(state.settings.volume); +$('muteChk').checked = state.settings.muted; +$('encoderSel').value = state.settings.encoder; +$('forceWide').checked = state.settings.forceWideScreen; + +drawFilmstripPlaceholder(); +syncPlayButton(); +updateExportPanel(); diff --git a/web/js/exporter.js b/web/js/exporter.js new file mode 100644 index 0000000..d061d21 --- /dev/null +++ b/web/js/exporter.js @@ -0,0 +1,286 @@ +// In-browser clip export via ffmpeg.wasm. +// +// Uses the single-threaded @ffmpeg/core build, which works without +// COOP/COEP headers so the app can be hosted from any static file server +// (including GitHub Pages). The engine (~31 MB) is fetched from a CDN the +// first time an export runs; files being edited never leave the machine. +// +// The filter graph mirrors the desktop app's FfmpegEncoder: per-segment +// trim/atrim + setpts reset, optional center zoom (scale+crop), optional +// speed change (setpts / atempo chain), then concat of every segment. + +const DEFAULT_URLS = { + ffmpegJs: 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.js', + // Self-contained module worker vendored in this repo (see the header of + // that file for why the stock package's worker can't be used from a CDN). + // FFmpeg#load() runs classWorkerURL workers with { type: "module" }, so + // the core must be the ESM build, whose default export the worker imports. + ffmpegWorkerJs: new URL('../vendor/ffmpeg-worker.js', import.meta.url).href, + utilJs: 'https://unpkg.com/@ffmpeg/util@0.12.1/dist/umd/index.js', + coreJs: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm/ffmpeg-core.js', + coreWasm: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm/ffmpeg-core.wasm', +}; + +// Overridable so tests (or self-hosting users) can serve the engine locally. +function engineUrls() { + return Object.assign({}, DEFAULT_URLS, window.SHADOWCLIP_FFMPEG_URLS || {}); +} + +function fmtNum(value) { + // Fixed-point, never exponent notation, no trailing zeros noise. + return Number(value.toFixed(6)).toFixed(6).replace(/0+$/, '').replace(/\.$/, ''); +} + +// Reasons stream-copy cannot be used, mirroring the desktop restrictions. +// Empty array means copy mode is allowed. +export function copyModeBlockers(segments, forceWideScreen) { + const blockers = []; + if (segments.length > 1) blockers.push('more than one segment'); + if (segments.some(s => s.speed !== 1)) blockers.push('speed change'); + if (segments.some(s => s.zoom !== 1)) blockers.push('zoom'); + if (forceWideScreen) blockers.push('force 16:9'); + return blockers; +} + +// Decompose a speed ratio into atempo factors, each within atempo's +// supported [0.5, 2] range. Equivalent to the desktop chain for its +// 0.25x-4x presets, but correct for arbitrary values too. +function atempoFactors(speed) { + const factors = []; + let remaining = speed; + while (remaining > 2) { + factors.push(2); + remaining /= 2; + } + while (remaining < 0.5) { + factors.push(0.5); + remaining /= 0.5; + } + factors.push(remaining); + return factors; +} + +// `fps` is the source's rational frame rate ({num, den}) when known. It is +// only used when a segment changes speed: mixed-rate concat output otherwise +// gets its timestamps quantized to the guessed encoder frame rate (observed +// on the ffmpeg 5.1 wasm core), so we normalize back to the source rate. +export function buildExportArgs({ segments, hasAudio, mode, forceWideScreen, inputName, outputName, fps = null }) { + if (mode === 'copy') { + const seg = segments[0]; + return [ + '-ss', fmtNum(seg.start), + '-i', inputName, + '-t', fmtNum(seg.end - seg.start), + '-c', 'copy', + '-avoid_negative_ts', 'make_zero', + '-movflags', 'faststart', + '-f', 'mp4', + '-y', outputName, + ]; + } + + let filter = ''; + const concatInputs = []; + segments.forEach((seg, i) => { + const idx = i + 1; + const dur = seg.end - seg.start; + const hasSpeed = seg.speed !== 1; + const suffix = hasSpeed ? 'tmp' : ''; + const zoomFilter = seg.zoom > 1 + ? `,scale=${seg.zoom}*iw:-1,crop=iw/${seg.zoom}:ih/${seg.zoom}` + : ''; + filter += `[0:v]trim=start=${fmtNum(seg.start)}:duration=${fmtNum(dur)},setpts=PTS-STARTPTS${zoomFilter}[v${idx}${suffix}];`; + if (hasAudio) + filter += `[0:a]atrim=start=${fmtNum(seg.start)}:duration=${fmtNum(dur)},asetpts=PTS-STARTPTS[a${idx}${suffix}];`; + if (hasSpeed) { + filter += `[v${idx}tmp]setpts=PTS/${fmtNum(seg.speed)}[v${idx}];`; + if (hasAudio) { + const chain = atempoFactors(seg.speed).map(f => `atempo=${fmtNum(f)}`).join(','); + filter += `[a${idx}tmp]${chain}[a${idx}];`; + } + } + concatInputs.push(hasAudio ? `[v${idx}][a${idx}]` : `[v${idx}]`); + }); + + const audioFlag = hasAudio ? 1 : 0; + const outPads = hasAudio ? '[vcat][acat]' : '[vcat]'; + filter += `${concatInputs.join('')}concat=n=${segments.length}:v=1:a=${audioFlag}${outPads}`; + + let videoPad = '[vcat]'; + if (fps && segments.some(s => s.speed !== 1)) { + filter += `;${videoPad}fps=${fps.num}/${fps.den}[vnorm]`; + videoPad = '[vnorm]'; + } + if (forceWideScreen) { + filter += `;${videoPad}setdar=16/9[vfinal]`; + videoPad = '[vfinal]'; + } + + return [ + '-i', inputName, + '-filter_complex', filter, + '-map', videoPad, + ...(hasAudio ? ['-map', '[acat]', '-c:a', 'aac', '-b:a', '192k'] : []), + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '25', + '-pix_fmt', 'yuv420p', + // Keep the filter graph's exact timestamps: with the default CFR + // snapping, segments whose speed differs from the first segment get + // frames dropped/duplicated and their timing subtly mangled. + '-vsync', 'vfr', + '-movflags', 'faststart', + '-f', 'mp4', + '-y', outputName, + ]; +} + +// Duration of the finished clip in seconds (speed changes included). +export function outputDuration(segments, mode) { + if (mode === 'copy') return segments[0].end - segments[0].start; + return segments.reduce((sum, s) => sum + (s.end - s.start) / s.speed, 0); +} + +let scriptsLoaded = null; +let enginePromise = null; +let engine = null; +const logListeners = new Set(); +const progressListeners = new Set(); + +function injectScript(src) { + return new Promise((resolve, reject) => { + const el = document.createElement('script'); + el.src = src; + el.onload = resolve; + el.onerror = () => reject(new Error(`Failed to load ${src}`)); + document.head.appendChild(el); + }); +} + +async function loadEngine(onStatus) { + if (engine) return engine; + if (!enginePromise) { + enginePromise = (async () => { + const urls = engineUrls(); + onStatus?.('Downloading ffmpeg engine…'); + if (!scriptsLoaded) + scriptsLoaded = Promise.all([injectScript(urls.ffmpegJs), injectScript(urls.utilJs)]); + await scriptsLoaded; + const { FFmpeg } = window.FFmpegWASM; + const { toBlobURL } = window.FFmpegUtil; + const ff = new FFmpeg(); + ff.on('log', ({ message }) => logListeners.forEach(fn => fn(message))); + ff.on('progress', ({ time }) => progressListeners.forEach(fn => fn(time))); + await ff.load({ + coreURL: await toBlobURL(urls.coreJs, 'text/javascript'), + wasmURL: await toBlobURL(urls.coreWasm, 'application/wasm'), + classWorkerURL: await toBlobURL(urls.ffmpegWorkerJs, 'text/javascript'), + }); + engine = ff; + return ff; + })().catch(err => { + enginePromise = null; + scriptsLoaded = null; // a failed script load must be retryable + throw new Error( + 'Could not load the ffmpeg engine (are you offline, or is the CDN blocked?): ' + err.message + ); + }); + } + return enginePromise; +} + +// Bumped on every cancel; exportClip aborts at its next checkpoint. Needed +// because terminate() only rejects in-flight worker calls — cancels landing +// during the engine download or file read would otherwise be lost. +let cancelSeq = 0; + +export function cancelExport() { + cancelSeq++; + if (!engine) return; + try { + engine.terminate(); + } catch { + // already dead + } + engine = null; + enginePromise = null; +} + +// Runs the export and resolves with a Blob of the finished MP4. +// hasAudio may be null (container the MP4 parser couldn't read) — the engine +// probes the file itself in that case. +export async function exportClip({ file, segments, hasAudio, mode, forceWideScreen, outputName, fps, onProgress, onStatus, onLog }) { + const seq = cancelSeq; + const throwIfCancelled = () => { + if (cancelSeq !== seq) { + const err = new Error('Export canceled'); + err.canceled = true; + throw err; + } + }; + + const ff = await loadEngine(onStatus); + throwIfCancelled(); + + const extMatch = /\.([A-Za-z0-9]+)$/.exec(file.name); + let inputName = 'input.' + (extMatch ? extMatch[1].toLowerCase() : 'mp4'); + if (outputName === inputName) inputName = 'in_' + inputName; + + const totalOut = outputDuration(segments, mode); + const logTail = []; + const logFn = message => { + logTail.push(message); + if (logTail.length > 40) logTail.shift(); + onLog?.(message); + }; + const progressFn = timeMicros => { + if (totalOut > 0 && Number.isFinite(Number(timeMicros))) + onProgress?.(Math.min(1, Math.max(0, Number(timeMicros) / 1e6 / totalOut))); + }; + logListeners.add(logFn); + progressListeners.add(progressFn); + + try { + onStatus?.('Reading file…'); + const data = new Uint8Array(await file.arrayBuffer()); + throwIfCancelled(); + await ff.writeFile(inputName, data); + throwIfCancelled(); + + let audio = hasAudio; + if (audio == null && mode !== 'copy') { + // '-i' alone exits nonzero but prints the stream list to the log. + const probeLines = []; + const probeFn = m => probeLines.push(m); + logListeners.add(probeFn); + try { + await ff.exec(['-hide_banner', '-i', inputName]); + } finally { + logListeners.delete(probeFn); + } + audio = probeLines.some(l => /Stream #\d+:\d+.*: Audio/.test(l)); + throwIfCancelled(); + } + + const args = buildExportArgs({ segments, hasAudio: !!audio, mode, forceWideScreen, inputName, outputName, fps }); + onStatus?.(mode === 'copy' ? 'Copying streams…' : 'Encoding…'); + const rc = await ff.exec(args); + if (rc !== 0) + throw new Error(`ffmpeg exited with code ${rc}\n${logTail.slice(-8).join('\n')}`); + const out = await ff.readFile(outputName); + if (!out || out.length === 0) throw new Error('ffmpeg produced an empty file'); + return new Blob([out.buffer], { type: 'video/mp4' }); + } finally { + logListeners.delete(logFn); + progressListeners.delete(progressFn); + if (engine === ff) { + for (const name of [inputName, outputName]) { + try { + await ff.deleteFile(name); + } catch { + // never written or already gone + } + } + } + } +} diff --git a/web/js/mp4.js b/web/js/mp4.js new file mode 100644 index 0000000..d829fb6 --- /dev/null +++ b/web/js/mp4.js @@ -0,0 +1,202 @@ +// Minimal ISO BMFF (MP4) parser. +// +// Frame-accurate stepping needs the video track's exact frame rate, which the +// HTML5 video API does not expose. This walks the top-level boxes of the file +// (skipping mdat without reading it), pulls the moov box into memory, and +// derives fps from the sample table: fps = timescale * samples / totalDelta. +// Also reports whether the file has an audio track, which drives the audio +// half of the export filter graph. + +const MAX_MOOV_BYTES = 100 * 1024 * 1024; + +function gcd(a, b) { + while (b) [a, b] = [b, a % b]; + return a; +} + +async function readAt(file, offset, length) { + const buf = await file.slice(offset, offset + length).arrayBuffer(); + return new DataView(buf); +} + +function fourcc(view, offset) { + return String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3) + ); +} + +// Walk top-level boxes reading only headers until moov is found, then load it. +async function findMoov(file) { + let offset = 0; + const fileSize = file.size; + while (offset + 8 <= fileSize) { + const headLen = Math.min(16, fileSize - offset); + const head = await readAt(file, offset, headLen); + let boxSize = head.getUint32(0); + const type = fourcc(head, 4); + let headerLen = 8; + if (boxSize === 1) { + if (headLen < 16) return null; + boxSize = Number(head.getBigUint64(8)); + headerLen = 16; + } else if (boxSize === 0) { + boxSize = fileSize - offset; + } + if (boxSize < headerLen) return null; // corrupt box, bail out + if (type === 'moov') { + if (boxSize > MAX_MOOV_BYTES) return null; + // Content only — callers walk child boxes from offset 0. + const contentStart = offset + headerLen; + const contentEnd = Math.min(offset + boxSize, fileSize); + return readAt(file, contentStart, contentEnd - contentStart); + } + offset += boxSize; + } + return null; +} + +// Yields {type, start, end} for each child box in view[start, end). +// start/end of the yielded entry delimit the box *content* (header excluded). +function* childBoxes(view, start, end) { + let offset = start; + while (offset + 8 <= end) { + let boxSize = view.getUint32(offset); + const type = fourcc(view, offset + 4); + let headerLen = 8; + if (boxSize === 1) { + if (offset + 16 > end) return; + boxSize = Number(view.getBigUint64(offset + 8)); + headerLen = 16; + } else if (boxSize === 0) { + boxSize = end - offset; + } + if (boxSize < headerLen || offset + boxSize > end) return; + yield { type, start: offset + headerLen, end: offset + boxSize }; + offset += boxSize; + } +} + +function findChild(view, start, end, type) { + for (const box of childBoxes(view, start, end)) + if (box.type === type) return box; + return null; +} + +function parseMdhd(view, box) { + // An all-ones duration is the spec's "unknown" sentinel (fragmented + // recordings) — report 0 so callers treat it as absent. + const version = view.getUint8(box.start); + if (version === 1) { + const duration = view.getBigUint64(box.start + 24); + return { + timescale: view.getUint32(box.start + 20), + duration: duration === 0xffffffffffffffffn ? 0 : Number(duration), + }; + } + const duration = view.getUint32(box.start + 16); + return { + timescale: view.getUint32(box.start + 12), + duration: duration === 0xffffffff ? 0 : duration, + }; +} + +// Sum of sample counts and total duration (in track timescale) from stts. +function parseStts(view, box) { + const entryCount = view.getUint32(box.start + 4); + let samples = 0; + let totalDelta = 0; + let offset = box.start + 8; + for (let i = 0; i < entryCount && offset + 8 <= box.end; i++, offset += 8) { + const count = view.getUint32(offset); + const delta = view.getUint32(offset + 4); + samples += count; + totalDelta += count * delta; + } + return { samples, totalDelta }; +} + +function parseTrak(view, trak) { + const mdia = findChild(view, trak.start, trak.end, 'mdia'); + if (!mdia) return null; + const mdhd = findChild(view, mdia.start, mdia.end, 'mdhd'); + const hdlr = findChild(view, mdia.start, mdia.end, 'hdlr'); + if (!mdhd || !hdlr) return null; + const handler = fourcc(view, hdlr.start + 8); // after version/flags + pre_defined + const { timescale, duration } = parseMdhd(view, mdhd); + + let samples = 0; + let totalDelta = 0; + const minf = findChild(view, mdia.start, mdia.end, 'minf'); + const stbl = minf && findChild(view, minf.start, minf.end, 'stbl'); + const stts = stbl && findChild(view, stbl.start, stbl.end, 'stts'); + if (stts) ({ samples, totalDelta } = parseStts(view, stts)); + + return { handler, timescale, duration, samples, totalDelta }; +} + +// Returns {fps, frameCount, videoDuration, hasAudio, hasVideo} or null when +// the file is not parseable MP4 (caller falls back to runtime fps estimation). +export async function parseMp4Info(file) { + let moov; + try { + moov = await findMoov(file); + } catch { + return null; + } + if (!moov) return null; + + const info = { + fps: null, + fpsNum: null, // exact rational frame rate: fpsNum/fpsDen + fpsDen: null, + frameCount: 0, + videoDuration: null, + hasAudio: false, + hasVideo: false, + }; + // A truncated or corrupt trak is indistinguishable from an absent one, so + // any parse anomaly downgrades hasAudio to null ("unknown") — the export + // path then probes the file with ffmpeg itself instead of silently + // dropping audio that ffmpeg could have salvaged. + let suspect = false; + let walked = 0; + for (const box of childBoxes(moov, 0, moov.byteLength)) { + walked = box.end; + if (box.type !== 'trak') continue; + let track; + try { + track = parseTrak(moov, box); + } catch { + suspect = true; + continue; + } + if (!track) { + suspect = true; + continue; + } + if (track.handler === 'soun') info.hasAudio = true; + if (track.handler === 'vide' && !info.hasVideo) { + info.hasVideo = true; + info.frameCount = track.samples; + if (track.timescale > 0 && track.duration > 0) + info.videoDuration = track.duration / track.timescale; + if (track.totalDelta > 0 && track.samples > 0 && track.timescale > 0) { + const fps = (track.timescale * track.samples) / track.totalDelta; + if (fps >= 1 && fps <= 1000) { + info.fps = fps; + const g = gcd(track.timescale * track.samples, track.totalDelta); + info.fpsNum = (track.timescale * track.samples) / g; + info.fpsDen = track.totalDelta / g; + } + } + } + } + // Clean moov children tile the content exactly; a short walk means a + // child box (possibly a trak) was dropped as truncated or overrunning. + if (walked < moov.byteLength) suspect = true; + if (suspect && !info.hasAudio) info.hasAudio = null; + return info.hasVideo || info.hasAudio ? info : null; +} diff --git a/web/vendor/ffmpeg-worker.js b/web/vendor/ffmpeg-worker.js new file mode 100644 index 0000000..8f76569 --- /dev/null +++ b/web/vendor/ffmpeg-worker.js @@ -0,0 +1,183 @@ +/** + * Self-contained ES-module worker for @ffmpeg/ffmpeg 0.12.10. + * + * This is dist/esm/worker.js from https://github.com/ffmpegwasm/ffmpeg.wasm + * (MIT License, Copyright (c) 2023 Jerome Wu) with its two relative imports + * (const.js, errors.js) inlined, so it can be loaded through a Blob URL as a + * module worker. The stock package can't be used cross-origin from a CDN: + * the UMD worker chunk requires a classic worker, but FFmpeg#load() always + * constructs `classWorkerURL` workers with { type: "module" }. + * + * ShadowClip Web serves this file same-origin and passes it as + * `classWorkerURL`; the ffmpeg core (ESM build) is fetched from a CDN and + * imported below via the coreURL blob. + */ + +// --- inlined from const.js / errors.js ------------------------------------- +const CORE_VERSION = "0.12.6"; +const CORE_URL = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/umd/ffmpeg-core.js`; +const FFMessageType = { + LOAD: "LOAD", + EXEC: "EXEC", + WRITE_FILE: "WRITE_FILE", + READ_FILE: "READ_FILE", + DELETE_FILE: "DELETE_FILE", + RENAME: "RENAME", + CREATE_DIR: "CREATE_DIR", + LIST_DIR: "LIST_DIR", + DELETE_DIR: "DELETE_DIR", + ERROR: "ERROR", + DOWNLOAD: "DOWNLOAD", + PROGRESS: "PROGRESS", + LOG: "LOG", + MOUNT: "MOUNT", + UNMOUNT: "UNMOUNT", +}; +const ERROR_UNKNOWN_MESSAGE_TYPE = new Error("unknown message type"); +const ERROR_NOT_LOADED = new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"); +const ERROR_IMPORT_FAILURE = new Error("failed to import ffmpeg-core.js"); +// --------------------------------------------------------------------------- + +let ffmpeg; +const load = async ({ coreURL: _coreURL, wasmURL: _wasmURL, workerURL: _workerURL, }) => { + const first = !ffmpeg; + try { + if (!_coreURL) + _coreURL = CORE_URL; + // when web worker type is `classic`. + importScripts(_coreURL); + } + catch { + if (!_coreURL) + _coreURL = CORE_URL.replace('/umd/', '/esm/'); + // when web worker type is `module`. + self.createFFmpegCore = (await import( + /* webpackIgnore: true */ /* @vite-ignore */ _coreURL)).default; + if (!self.createFFmpegCore) { + throw ERROR_IMPORT_FAILURE; + } + } + const coreURL = _coreURL; + const wasmURL = _wasmURL ? _wasmURL : _coreURL.replace(/.js$/g, ".wasm"); + const workerURL = _workerURL + ? _workerURL + : _coreURL.replace(/.js$/g, ".worker.js"); + ffmpeg = await self.createFFmpegCore({ + // Fix `Overload resolution failed.` when using multi-threaded ffmpeg-core. + // Encoded wasmURL and workerURL in the URL as a hack to fix locateFile issue. + mainScriptUrlOrBlob: `${coreURL}#${btoa(JSON.stringify({ wasmURL, workerURL }))}`, + }); + ffmpeg.setLogger((data) => self.postMessage({ type: FFMessageType.LOG, data })); + ffmpeg.setProgress((data) => self.postMessage({ + type: FFMessageType.PROGRESS, + data, + })); + return first; +}; +const exec = ({ args, timeout = -1 }) => { + ffmpeg.setTimeout(timeout); + ffmpeg.exec(...args); + const ret = ffmpeg.ret; + ffmpeg.reset(); + return ret; +}; +const writeFile = ({ path, data }) => { + ffmpeg.FS.writeFile(path, data); + return true; +}; +const readFile = ({ path, encoding }) => ffmpeg.FS.readFile(path, { encoding }); +const deleteFile = ({ path }) => { + ffmpeg.FS.unlink(path); + return true; +}; +const rename = ({ oldPath, newPath }) => { + ffmpeg.FS.rename(oldPath, newPath); + return true; +}; +const createDir = ({ path }) => { + ffmpeg.FS.mkdir(path); + return true; +}; +const listDir = ({ path }) => { + const names = ffmpeg.FS.readdir(path); + const nodes = []; + for (const name of names) { + const stat = ffmpeg.FS.stat(`${path}/${name}`); + const isDir = ffmpeg.FS.isDir(stat.mode); + nodes.push({ name, isDir }); + } + return nodes; +}; +const deleteDir = ({ path }) => { + ffmpeg.FS.rmdir(path); + return true; +}; +const mount = ({ fsType, options, mountPoint }) => { + const str = fsType; + const fs = ffmpeg.FS.filesystems[str]; + if (!fs) + return false; + ffmpeg.FS.mount(fs, options, mountPoint); + return true; +}; +const unmount = ({ mountPoint }) => { + ffmpeg.FS.unmount(mountPoint); + return true; +}; +self.onmessage = async ({ data: { id, type, data: _data }, }) => { + const trans = []; + let data; + try { + if (type !== FFMessageType.LOAD && !ffmpeg) + throw ERROR_NOT_LOADED; + switch (type) { + case FFMessageType.LOAD: + data = await load(_data); + break; + case FFMessageType.EXEC: + data = exec(_data); + break; + case FFMessageType.WRITE_FILE: + data = writeFile(_data); + break; + case FFMessageType.READ_FILE: + data = readFile(_data); + break; + case FFMessageType.DELETE_FILE: + data = deleteFile(_data); + break; + case FFMessageType.RENAME: + data = rename(_data); + break; + case FFMessageType.CREATE_DIR: + data = createDir(_data); + break; + case FFMessageType.LIST_DIR: + data = listDir(_data); + break; + case FFMessageType.DELETE_DIR: + data = deleteDir(_data); + break; + case FFMessageType.MOUNT: + data = mount(_data); + break; + case FFMessageType.UNMOUNT: + data = unmount(_data); + break; + default: + throw ERROR_UNKNOWN_MESSAGE_TYPE; + } + } + catch (e) { + self.postMessage({ + id, + type: FFMessageType.ERROR, + data: e.toString(), + }); + return; + } + if (data instanceof Uint8Array) { + trans.push(data.buffer); + } + self.postMessage({ id, type, data }, trans); +};