Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:8000>. 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.
288 changes: 288 additions & 0 deletions web/css/style.css
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading