diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md
index f728d91bd..94db284b7 100644
--- a/app/features/scanner/README.md
+++ b/app/features/scanner/README.md
@@ -28,7 +28,7 @@ running capture (`LiveView`), a past session (`PastSessionView`) and a
scanned VoD (`VodView`): header, clip strip, then match cards
(`components/MatchCard.tsx`) newest first. Views are picked by the `view`
search param (`scanner-search-params.ts`: `live`, `session&id=`,
-`vod&name=`, `clips`, `debug` and the dev-only `fixtures`). Nothing links to
+`vod&name=`, `clips`, `debug` and the dev-only `fixtures` and `montage`). Nothing links to
the `debug` screenshot view: dropping an image on the landing's File card
opens it, for anyone, through the same handoff Inspect uses.
@@ -84,7 +84,7 @@ opens it, for anyone, through the same handoff Inspect uses.
`Save frame as fixture` (Settings → Debug, live only), `?telemetry=true`,
and the `Raw detections` disclosure inside a match card (the per-event
cards with Inspect). The screenshot view (`ScreenshotPage.tsx`) is not
- gated; the fixtures view is dev-only.
+ gated; the fixtures and montage views are dev-only.
## Clips
@@ -149,6 +149,53 @@ scored `kills² + kills / span`. Both controllers run the same
seconds (a packet-copied clip starts at a keyframe), and a card's deaths
and kills get a ▶ when a clip covers their `t`.
+## Tournament montage (dev only, PoC)
+
+`view=montage` (Settings → Debug → Tournament montage, development only)
+builds the basis of a tournament highlight video. `pnpm vods:download `
+writes the tournament's match VoDs to `scripts/output/vods//` together
+with `tournament.json` (name, start, logo, top 8, each VoD's POV team —
+`MontageManifest` in `core/montage.ts`) and the logos it names; the view
+opens that folder through the File System Access API's directory picker
+(Chrome). `components/montage.ts` scans every VoD without games yet through
+`startVodScan` (`clips: false`: saved like any file scan, so they also show
+in the landing's list) and lists every streak meeting the criteria as a
+candidate, best score first (`montageWindows`): min splats (default 3) and
+a length cap per splat count before fast-forwarding (defaults 2: 20 s,
+3: 30 s, 4: 45 s, 5+: 60 s), picked on the page and kept in localStorage.
+Candidates are grouped by the POV player's base weapon (read off the
+game's scoreboard seat, variants folded to their base). Picking one hides
+that team's other candidates (a cast VoD has no team: only its own go). The scanned games, the picks (stored whole, so changing
+the criteria never drops one) and the folder's handle live in the
+`montages` store (`store/montages.ts`, one record per tournament): a reload
+restores the latest montage at once and reconnects the folder itself when
+Chrome kept the permission, else `Reconnect folder` asks again.
+
+Rendering (`capture/montage-render.ts`, overlays in `montage-overlays.ts`)
+re-encodes everything at 1920×1080 60 fps: the title card, the picks in
+order, then the top 8 (player names for the top 3, and each team's comp:
+`montage-comps.ts` builds it the way the results image export does, from
+the scans alone — every scoreboard names both sides' weapons and the POV
+seat tells which side is the streamer's team, so a team needs VoDs of its
+own or of its opponents to get one). The cards and each
+clip's ribbon (round, both teams, the POV player and their profile link;
+fades out after 5 s) are the image export's graphic blocks
+(`features/img-export`), mounted off screen by `MontageGraphics.tsx` in the
+dark theme and rasterized with snapdom at the scale that makes them full HD —
+the renderer asks for them through `setMontageGraphicCapture`, so the page
+must stay open. The manifest carries what they show (round names, both
+sides, the streamer, top-3 players, tier, organization, counts — no
+weapons: those come from the scans) and the
+download script saves every image it names next to the VoDs. Dead time
+between two kills — from 3 s after one to 3 s before the next, when at
+least 2 s — plays at 4×, muted and badged (`playbackSegments`). Each clip's
+sound is normalized to -16 LUFS (`core/loudness.ts`: BS.1770 integrated
+loudness, at most +24 dB) with a look-ahead limiter holding peaks under
+-1 dBFS. An optional music (or video) file replaces the game sound for the
+whole video: looped as needed, faded in and out, normalized the same way;
+it is kept in the montage's record. Chrome's save dialog streams the MP4 to disk; without it the file
+is built in memory and downloaded.
+
## Commands
```sh
diff --git a/app/features/scanner/capture/montage-overlays.ts b/app/features/scanner/capture/montage-overlays.ts
new file mode 100644
index 000000000..7624392e1
--- /dev/null
+++ b/app/features/scanner/capture/montage-overlays.ts
@@ -0,0 +1,92 @@
+/**
+ * What the tournament montage draws over its 1920×1080 frames: the
+ * pre-rendered cards (entering with a fade and a slight settle) and, on a
+ * clip, its ribbon (for `RIBBON_SECONDS`) and the fast-forward badge.
+ */
+
+export const MONTAGE_WIDTH = 1920;
+export const MONTAGE_HEIGHT = 1080;
+
+const CARD_FADE_SECONDS = 0.5;
+const CARD_SETTLE_SECONDS = 1.2;
+const CARD_SETTLE_ZOOM = 0.03;
+const RIBBON_SECONDS = 5;
+const RIBBON_FADE_SECONDS = 0.4;
+const RIBBON_RISE_PX = 24;
+const RIBBON_MARGIN_PX = 40;
+
+type Context = OffscreenCanvasRenderingContext2D;
+
+/** A full-frame card `t` seconds into its `seconds` on screen. */
+export function drawCard(
+ ctx: Context,
+ card: ImageBitmap,
+ { t, seconds }: { t: number; seconds: number },
+): void {
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, MONTAGE_WIDTH, MONTAGE_HEIGHT);
+ const zoom =
+ 1 + CARD_SETTLE_ZOOM * (1 - easeOut(Math.min(1, t / CARD_SETTLE_SECONDS)));
+ const width = MONTAGE_WIDTH * zoom;
+ const height = MONTAGE_HEIGHT * zoom;
+ ctx.save();
+ ctx.globalAlpha = Math.min(
+ 1,
+ Math.max(0, Math.min(t, seconds - t) / CARD_FADE_SECONDS),
+ );
+ ctx.drawImage(
+ card,
+ (MONTAGE_WIDTH - width) / 2,
+ (MONTAGE_HEIGHT - height) / 2,
+ width,
+ height,
+ );
+ ctx.restore();
+}
+
+/** The clip's ribbon, bottom center, `t` seconds into the clip; gone after `RIBBON_SECONDS`. */
+export function drawRibbon(ctx: Context, ribbon: ImageBitmap, t: number): void {
+ if (t >= RIBBON_SECONDS) return;
+ const fadeIn = Math.min(1, t / RIBBON_FADE_SECONDS);
+ const fadeOut = Math.min(1, (RIBBON_SECONDS - t) / RIBBON_FADE_SECONDS);
+ ctx.save();
+ ctx.globalAlpha = Math.min(fadeIn, fadeOut);
+ ctx.drawImage(
+ ribbon,
+ (MONTAGE_WIDTH - ribbon.width) / 2,
+ MONTAGE_HEIGHT -
+ ribbon.height -
+ RIBBON_MARGIN_PX +
+ (1 - easeOut(fadeIn)) * RIBBON_RISE_PX,
+ );
+ ctx.restore();
+}
+
+/** `▶▶ 4×` on the right, under the special gauge, while dead time is fast-forwarded. */
+export function drawFastForwardBadge(ctx: Context, speed: number): void {
+ const width = 190;
+ const height = 84;
+ const x = MONTAGE_WIDTH - width - 48;
+ const y = 260;
+ ctx.fillStyle = "rgb(0 0 0 / 0.6)";
+ ctx.beginPath();
+ ctx.roundRect(x, y, width, height, 20);
+ ctx.fill();
+ ctx.fillStyle = "#fff";
+ for (const offset of [0, 30]) {
+ ctx.beginPath();
+ ctx.moveTo(x + 28 + offset, y + 22);
+ ctx.lineTo(x + 58 + offset, y + height / 2);
+ ctx.lineTo(x + 28 + offset, y + height - 22);
+ ctx.closePath();
+ ctx.fill();
+ }
+ ctx.textAlign = "left";
+ ctx.textBaseline = "middle";
+ ctx.font = `700 44px ${getComputedStyle(document.body).fontFamily}`;
+ ctx.fillText(`${speed}×`, x + 104, y + height / 2 + 2);
+}
+
+function easeOut(x: number): number {
+ return 1 - (1 - x) ** 3;
+}
diff --git a/app/features/scanner/capture/montage-render.ts b/app/features/scanner/capture/montage-render.ts
new file mode 100644
index 000000000..22f17c2e1
--- /dev/null
+++ b/app/features/scanner/capture/montage-render.ts
@@ -0,0 +1,401 @@
+/**
+ * Renders the tournament montage into one MP4, in the browser: the title
+ * card, the picked clips played back per their segments (each with its
+ * ribbon; fast-forwarded stretches muted and badged), then the standings
+ * card. Cards and ribbons come in as images (the page renders them, see
+ * `MontageGraphics.tsx`). Every frame is decoded with mediabunny, drawn on a
+ * canvas and re-encoded, unlike `vod-clips.ts`'s packet copy: speed changes
+ * and overlays need new frames. Sound is laid out per section in an
+ * `OfflineAudioContext` at the output rate, so each section's audio lasts
+ * exactly as long as its frames and clips of different sample rates share
+ * one track; each clip is then normalized to `TARGET_LOUDNESS_LUFS` with
+ * its peaks limited. Given a music file, its sound (looped as needed, faded
+ * in and out, normalized the same way) replaces the game sound throughout.
+ */
+import {
+ ALL_FORMATS,
+ AudioBufferSink,
+ AudioBufferSource,
+ BlobSource,
+ BufferTarget,
+ CanvasSource,
+ getFirstEncodableAudioCodec,
+ Input,
+ Mp4OutputFormat,
+ Output,
+ Quality,
+ StreamTarget,
+ type StreamTargetChunk,
+ VideoSampleSink,
+} from "mediabunny";
+import { integratedLoudness, limitPeaks } from "../core/loudness";
+import { type PlaybackSegment, playbackDuration } from "../core/montage";
+import {
+ drawCard,
+ drawFastForwardBadge,
+ drawRibbon,
+ MONTAGE_HEIGHT,
+ MONTAGE_WIDTH,
+} from "./montage-overlays";
+
+const FPS = 60;
+/** the live ring's rate: plenty for 1080p60 footage that gets edited further */
+const VIDEO_BITRATE = 16_000_000;
+const AUDIO_SAMPLE_RATE = 48_000;
+const AUDIO_CHANNELS = 2;
+const TITLE_CARD_SECONDS = 4;
+const STANDINGS_CARD_SECONDS = 8;
+/** a ramp at each cut so the sound doesn't click */
+const AUDIO_EDGE_FADE_SECONDS = 0.02;
+/** a streaming-platform level with headroom left for the edit */
+const TARGET_LOUDNESS_LUFS = -16;
+/** a near-silent clip isn't lifted into its noise floor */
+const MAX_GAIN_DB = 24;
+/** the limiter holds sample peaks under -1 dBFS */
+const PEAK_CEILING = 10 ** (-1 / 20);
+const MUSIC_FADE_IN_SECONDS = 1;
+const MUSIC_FADE_OUT_SECONDS = 3;
+
+export interface MontageClipSource {
+ file: File;
+ segments: PlaybackSegment[];
+ /** drawn over the clip's first seconds */
+ ribbon: ImageBitmap | null;
+}
+
+export interface MontageRenderProgress {
+ /** output seconds rendered */
+ done: number;
+ total: number;
+}
+
+/**
+ * Renders into `writable` (a file the user picked) or, without one, into
+ * memory, returning the MP4. Throws when `signal` aborts.
+ */
+export async function renderMontage({
+ title,
+ clips,
+ standings,
+ music,
+ writable,
+ signal,
+ onProgress,
+}: {
+ /** full frame */
+ title: ImageBitmap;
+ clips: MontageClipSource[];
+ /** full frame */
+ standings: ImageBitmap;
+ /** its sound replaces the game sound */
+ music: File | null;
+ writable: WritableStream | null;
+ signal: AbortSignal;
+ onProgress: (progress: MontageRenderProgress) => void;
+}): Promise {
+ const canvas = new OffscreenCanvas(MONTAGE_WIDTH, MONTAGE_HEIGHT);
+ const ctx = canvas.getContext("2d")!;
+ const bufferTarget = writable ? null : new BufferTarget();
+ const format = new Mp4OutputFormat(
+ writable ? {} : { fastStart: "in-memory" },
+ );
+ const output = new Output({
+ format,
+ target: writable
+ ? new StreamTarget(writable, { chunked: true })
+ : bufferTarget!,
+ });
+ const audioCodec = await getFirstEncodableAudioCodec(["aac", "opus"], {
+ numberOfChannels: AUDIO_CHANNELS,
+ sampleRate: AUDIO_SAMPLE_RATE,
+ });
+ if (!audioCodec) throw new Error("this browser can't encode audio");
+ const videoSource = new CanvasSource(canvas, {
+ codec: "avc",
+ quality: new Quality({ bitrate: VIDEO_BITRATE }),
+ });
+ const audioSource = new AudioBufferSource({
+ codec: audioCodec,
+ quality: new Quality("high"),
+ });
+ output.addVideoTrack(videoSource, { frameRate: FPS });
+ output.addAudioTrack(audioSource);
+
+ const titleFrames = Math.round(TITLE_CARD_SECONDS * FPS);
+ const standingsFrames = Math.round(STANDINGS_CARD_SECONDS * FPS);
+ const totalFrames =
+ titleFrames +
+ standingsFrames +
+ clips.reduce(
+ (sum, clip) => sum + Math.round(playbackDuration(clip.segments) * FPS),
+ 0,
+ );
+ const total = totalFrames / FPS;
+ const musicTrack = music ? await musicAudio(music, totalFrames) : null;
+ /** the section starting at the current frame: its slice of the music, else silence */
+ const backingAudio = (frameCount: number) =>
+ musicTrack
+ ? audioSlice(musicTrack, framesDone, frameCount)
+ : silence(frameCount);
+ let framesDone = 0;
+ const addFrame = async () => {
+ signal.throwIfAborted();
+ await videoSource.add(framesDone / FPS, 1 / FPS);
+ framesDone++;
+ if (framesDone % FPS === 0) onProgress({ done: framesDone / FPS, total });
+ };
+
+ await output.start();
+ try {
+ await renderCard(title, titleFrames);
+ for (const clip of clips) {
+ await renderClip(clip);
+ }
+ await renderCard(standings, standingsFrames);
+ await output.finalize();
+ } catch (error) {
+ await output.cancel();
+ throw error;
+ }
+ onProgress({ done: total, total });
+ return bufferTarget?.buffer
+ ? new Blob([bufferTarget.buffer], { type: format.mimeType })
+ : null;
+
+ async function renderCard(
+ card: ImageBitmap,
+ frameCount: number,
+ ): Promise {
+ await audioSource.add(backingAudio(frameCount));
+ for (let i = 0; i < frameCount; i++) {
+ drawCard(ctx, card, { t: i / FPS, seconds: frameCount / FPS });
+ await addFrame();
+ }
+ }
+
+ async function renderClip(clip: MontageClipSource): Promise {
+ const input = new Input({
+ formats: ALL_FORMATS,
+ source: new BlobSource(clip.file),
+ });
+ try {
+ const videoTrack = await input.getPrimaryVideoTrack();
+ if (!videoTrack || !(await videoTrack.canDecode())) {
+ throw new Error(`${clip.file.name}: the video can't be decoded`);
+ }
+ const audioTrack = await input.getPrimaryAudioTrack();
+ const timeline = outputTimeline(clip.segments);
+ const frameCount = Math.round(timeline.duration * FPS);
+
+ await audioSource.add(
+ musicTrack || !audioTrack || !(await audioTrack.canDecode())
+ ? backingAudio(frameCount)
+ : await clipAudio(
+ new AudioBufferSink(audioTrack),
+ timeline,
+ frameCount,
+ ),
+ );
+
+ // mid-frame, so float error never lands a boundary on the previous source frame
+ const frames = Array.from({ length: frameCount }, (_, frame) =>
+ timeline.at((frame + 0.5) / FPS),
+ );
+ const samples = new VideoSampleSink(videoTrack).samplesAtTimestamps(
+ frames.map((frame) => frame.source),
+ );
+ let i = 0;
+ for await (const sample of samples) {
+ if (sample) {
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, MONTAGE_WIDTH, MONTAGE_HEIGHT);
+ sample.drawWithFit(ctx, { fit: "contain" });
+ sample.close();
+ }
+ if (clip.ribbon) drawRibbon(ctx, clip.ribbon, i / FPS);
+ const speed = frames[i]!.speed;
+ if (speed !== 1) drawFastForwardBadge(ctx, speed);
+ await addFrame();
+ i++;
+ }
+ } finally {
+ input.dispose();
+ }
+ }
+}
+
+/** Where each segment lands in the clip's output, and the source moment shown at an output time. */
+function outputTimeline(segments: readonly PlaybackSegment[]) {
+ let cursor = 0;
+ const placed = segments.map((segment) => {
+ const start = cursor;
+ cursor += (segment.to - segment.from) / segment.speed;
+ return { ...segment, start, end: cursor };
+ });
+ return {
+ placed,
+ duration: cursor,
+ at(t: number) {
+ const segment =
+ placed.find((candidate) => t < candidate.end) ?? placed.at(-1)!;
+ return {
+ source: Math.min(
+ segment.to,
+ segment.from + (t - segment.start) * segment.speed,
+ ),
+ speed: segment.speed,
+ };
+ },
+ };
+}
+
+/** The clip's sound, normal-speed segments only (fast-forwarded ones stay silent), loudness-normalized. */
+async function clipAudio(
+ sink: AudioBufferSink,
+ timeline: ReturnType,
+ frameCount: number,
+): Promise {
+ const context = new OfflineAudioContext(
+ AUDIO_CHANNELS,
+ samplesFor(frameCount),
+ AUDIO_SAMPLE_RATE,
+ );
+ for (const segment of timeline.placed) {
+ if (segment.speed !== 1) continue;
+ const buffer = await sourceAudio(sink, segment.from, segment.to);
+ if (!buffer) continue;
+ const gain = context.createGain();
+ const edge = Math.min(
+ AUDIO_EDGE_FADE_SECONDS,
+ (segment.end - segment.start) / 2,
+ );
+ gain.gain.setValueAtTime(0, segment.start);
+ gain.gain.linearRampToValueAtTime(1, segment.start + edge);
+ gain.gain.setValueAtTime(1, segment.end - edge);
+ gain.gain.linearRampToValueAtTime(0, segment.end);
+ gain.connect(context.destination);
+ const node = new AudioBufferSourceNode(context, { buffer });
+ node.connect(gain);
+ node.start(segment.start);
+ }
+ return normalized(await context.startRendering());
+}
+
+/** The buffer raised or lowered in place to `TARGET_LOUDNESS_LUFS`, its peaks limited under `PEAK_CEILING`. */
+function normalized(buffer: AudioBuffer): AudioBuffer {
+ const channels = Array.from({ length: buffer.numberOfChannels }, (_, i) =>
+ buffer.getChannelData(i),
+ );
+ const loudness = integratedLoudness(channels, buffer.sampleRate);
+ if (!Number.isFinite(loudness)) return buffer;
+ const gain =
+ 10 ** (Math.min(MAX_GAIN_DB, TARGET_LOUDNESS_LUFS - loudness) / 20);
+ for (const channel of channels) {
+ for (let i = 0; i < channel.length; i++) channel[i] = channel[i]! * gain;
+ }
+ limitPeaks(channels, buffer.sampleRate, PEAK_CEILING);
+ return buffer;
+}
+
+/** The source's sound from `from` to `to` as one buffer at its own rate; null when there is none. */
+async function sourceAudio(
+ sink: AudioBufferSink,
+ from: number,
+ to: number,
+): Promise {
+ let target: AudioBuffer | null = null;
+ for await (const { buffer, timestamp } of sink.buffers(from, to)) {
+ target ??= new AudioBuffer({
+ length: Math.ceil((to - from) * buffer.sampleRate),
+ numberOfChannels: buffer.numberOfChannels,
+ sampleRate: buffer.sampleRate,
+ });
+ const offset = Math.round((timestamp - from) * buffer.sampleRate);
+ if (offset >= target.length) continue;
+ for (let channel = 0; channel < target.numberOfChannels; channel++) {
+ const data = buffer.getChannelData(
+ Math.min(channel, buffer.numberOfChannels - 1),
+ );
+ target.copyToChannel(
+ data.subarray(Math.max(0, -offset)),
+ channel,
+ Math.max(0, offset),
+ );
+ }
+ }
+ return target;
+}
+
+/** The music as one track the length of the video: looped as needed, faded in and out, normalized. */
+async function musicAudio(
+ file: File,
+ frameCount: number,
+): Promise {
+ const input = new Input({
+ formats: ALL_FORMATS,
+ source: new BlobSource(file),
+ });
+ try {
+ const track = await input.getPrimaryAudioTrack();
+ if (!track || !(await track.canDecode())) {
+ throw new Error(`${file.name}: no sound that can be decoded`);
+ }
+ const buffer = await sourceAudio(
+ new AudioBufferSink(track),
+ 0,
+ await track.computeDuration(),
+ );
+ if (!buffer) throw new Error(`${file.name}: the sound is empty`);
+
+ const length = samplesFor(frameCount);
+ const context = new OfflineAudioContext(
+ AUDIO_CHANNELS,
+ length,
+ AUDIO_SAMPLE_RATE,
+ );
+ const end = length / AUDIO_SAMPLE_RATE;
+ const gain = context.createGain();
+ gain.gain.setValueAtTime(0, 0);
+ gain.gain.linearRampToValueAtTime(1, MUSIC_FADE_IN_SECONDS);
+ gain.gain.setValueAtTime(1, Math.max(0, end - MUSIC_FADE_OUT_SECONDS));
+ gain.gain.linearRampToValueAtTime(0, end);
+ gain.connect(context.destination);
+ const node = new AudioBufferSourceNode(context, { buffer, loop: true });
+ node.connect(gain);
+ node.start(0);
+ return normalized(await context.startRendering());
+ } finally {
+ input.dispose();
+ }
+}
+
+/** `frameCount` frames' worth of `track` from frame `fromFrame` on. */
+function audioSlice(
+ track: AudioBuffer,
+ fromFrame: number,
+ frameCount: number,
+): AudioBuffer {
+ const slice = silence(frameCount);
+ const offset = samplesFor(fromFrame);
+ for (let channel = 0; channel < AUDIO_CHANNELS; channel++) {
+ slice.copyToChannel(
+ track
+ .getChannelData(Math.min(channel, track.numberOfChannels - 1))
+ .subarray(offset, offset + slice.length),
+ channel,
+ );
+ }
+ return slice;
+}
+
+function silence(frameCount: number): AudioBuffer {
+ return new AudioBuffer({
+ length: samplesFor(frameCount),
+ numberOfChannels: AUDIO_CHANNELS,
+ sampleRate: AUDIO_SAMPLE_RATE,
+ });
+}
+
+function samplesFor(frameCount: number): number {
+ return Math.round((frameCount / FPS) * AUDIO_SAMPLE_RATE);
+}
diff --git a/app/features/scanner/components/MontageGraphics.module.css b/app/features/scanner/components/MontageGraphics.module.css
new file mode 100644
index 000000000..deaef5f36
--- /dev/null
+++ b/app/features/scanner/components/MontageGraphics.module.css
@@ -0,0 +1,114 @@
+/* on screen (snapdom reads layout) but out of sight and reach */
+.stage {
+ position: fixed;
+ inset: 0 auto auto 0;
+ z-index: -1;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.graphic {
+ width: fit-content;
+}
+
+.frame {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background:
+ radial-gradient(
+ ellipse 80% 55% at 50% -10%,
+ color-mix(in oklch, var(--color-fg-accent) 22%, transparent),
+ transparent 70%
+ ),
+ var(--color-bg);
+}
+
+.ribbon {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ align-items: center;
+ gap: var(--s-6);
+}
+
+.ribbonRound {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-0-5);
+ min-width: 0;
+}
+
+.ribbonRoundName {
+ overflow: hidden;
+ font-size: var(--font-lg);
+ font-weight: var(--weight-extra);
+ text-overflow: ellipsis;
+}
+
+.ribbonTeams {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+}
+
+.ribbonTeam,
+.ribbonTeamPov {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding: var(--s-1-5) var(--s-3);
+ background-color: var(--color-bg-high);
+ border: 1.5px solid color-mix(in oklch, var(--color-text) 8%, transparent);
+ border-radius: var(--radius-box);
+}
+
+.ribbonTeamPov {
+ border-color: color-mix(in oklch, var(--color-fg-accent) 60%, transparent);
+}
+
+.ribbonTeamName {
+ max-width: 16rem;
+ overflow: hidden;
+ font-size: var(--font-md);
+ font-weight: var(--weight-extra);
+ text-overflow: ellipsis;
+}
+
+.ribbonVersus {
+ font-size: var(--font-xs);
+ font-weight: var(--weight-extra);
+ text-transform: uppercase;
+ color: var(--color-text-high);
+}
+
+.ribbonPov {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--s-3);
+ min-width: 0;
+}
+
+.ribbonPovText {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-0-5);
+ min-width: 0;
+ font-size: var(--font-xs);
+ font-weight: var(--weight-extra);
+ color: var(--color-text-high);
+}
+
+.ribbonPovName {
+ overflow: hidden;
+ font-size: var(--font-md);
+ color: var(--color-text);
+ text-overflow: ellipsis;
+}
+
+.ribbonWeapon {
+ display: flex;
+ padding: var(--s-1);
+ background-color: oklch(0% 0 0 / 0.3);
+ border-radius: var(--radius-field);
+}
diff --git a/app/features/scanner/components/MontageGraphics.tsx b/app/features/scanner/components/MontageGraphics.tsx
new file mode 100644
index 000000000..02727f8af
--- /dev/null
+++ b/app/features/scanner/components/MontageGraphics.tsx
@@ -0,0 +1,306 @@
+/**
+ * The montage's cards and clip ribbons, built from the image export's
+ * graphic blocks (`~/features/img-export`) so the video matches the
+ * exported result images. The renderer (and the results image download)
+ * asks for one at a time through `setMontageGraphicCapture`: the stage
+ * mounts it off screen in the dark theme, snapdom rasterizes it (the video's
+ * at the scale that makes them full-HD sized) and it unmounts again.
+ */
+import * as React from "react";
+import { createPortal } from "react-dom";
+import { Avatar } from "~/components/Avatar";
+import { WeaponImage } from "~/components/Image";
+import {
+ GraphicBoxLabel,
+ GraphicContainer,
+ GraphicFooter,
+ GraphicPlacementCell,
+ GraphicSiteUrl,
+ GraphicStat,
+ GraphicStatsRow,
+ GraphicTeamRow,
+ GraphicTeamsList,
+} from "~/features/img-export/components/Graphic";
+import { TournamentGraphicHeader } from "~/features/img-export/components/TournamentResultsGraphic";
+import type { MainWeaponId } from "~/modules/in-game-lists/types";
+import { tournamentPage } from "~/utils/urls";
+import type { MontageManifest, MontageManifestVod } from "../core/montage";
+import styles from "./MontageGraphics.module.css";
+import {
+ type MontageGraphic,
+ montageImageUrl,
+ setMontageGraphicCapture,
+} from "./montage";
+
+/** the full-frame graphics' CSS size times this is 1920×1080 */
+const FRAME_SCALE: Record<"title" | "standings", number> = {
+ title: 2.4,
+ standings: 1.2,
+};
+const RIBBON_SCALE = 1.25;
+/** the image export's own scale (ImageExportDialog.tsx) */
+const RESULTS_SCALE = 1.75;
+const TOP_TEAMS_WITH_PLAYERS_IN_VIDEO = 3;
+
+interface CaptureRequest {
+ graphic: MontageGraphic;
+ resolve: (image: Blob) => void;
+ reject: (error: unknown) => void;
+}
+
+export function MontageGraphicStage({
+ manifest,
+ comps,
+}: {
+ manifest: MontageManifest;
+ /** by team id */
+ comps: ReadonlyMap;
+}) {
+ const [request, setRequest] = React.useState(null);
+ const graphicRef = React.useRef(null);
+
+ // the renderer runs outside React and asks for its graphics one at a time
+ React.useEffect(() => {
+ setMontageGraphicCapture(
+ (graphic) =>
+ new Promise((resolve, reject) =>
+ setRequest({ graphic, resolve, reject }),
+ ),
+ );
+ return () => setMontageGraphicCapture(null);
+ }, []);
+
+ // capture once the requested graphic has committed to the DOM
+ React.useEffect(() => {
+ if (!request || !graphicRef.current) return;
+ const scale =
+ request.graphic.kind === "ribbon"
+ ? RIBBON_SCALE
+ : request.graphic.kind === "results"
+ ? RESULTS_SCALE
+ : FRAME_SCALE[request.graphic.kind];
+ captureImage(graphicRef.current, scale)
+ .then(request.resolve, request.reject)
+ // the next request can arrive before this settles: only this one is dropped
+ .finally(() =>
+ setRequest((current) => (current === request ? null : current)),
+ );
+ }, [request]);
+
+ if (!request) return null;
+
+ return createPortal(
+
+
+ {request.graphic.kind === "title" ? (
+
+ ) : request.graphic.kind === "standings" ? (
+
+
+
+ ) : request.graphic.kind === "results" ? (
+
+ ) : (
+
+ )}
+
+
,
+ document.body,
+ );
+}
+
+function TitleGraphic({ manifest }: { manifest: MontageManifest }) {
+ return (
+
+
+
+
+ {manifest.teamsCount}
+ {manifest.playersCount}
+
+
+ Highlights
+
+
+
+
+ );
+}
+
+/** The results image export's layout: the top teams with their comps, rosters for the first `rosterCount`. */
+function ResultsCard({
+ manifest,
+ comps,
+ rosterCount,
+ width,
+}: {
+ manifest: MontageManifest;
+ comps: ReadonlyMap;
+ rosterCount: number;
+ width?: number;
+}) {
+ return (
+
+
+
+ {manifest.topTeams.map((team, i) => (
+ ({
+ name: player.name,
+ countryCode: player.countryCode ?? undefined,
+ }))
+ : [],
+ weapons: comps.get(team.id) ?? [],
+ }}
+ highlighted={team.placement === 1}
+ leading={}
+ />
+ ))}
+
+
+
+ {manifest.teamsCount} teams · {manifest.playersCount} players
+
+
+
+
+ );
+}
+
+function RibbonGraphic({
+ vod,
+ weaponId,
+}: {
+ vod: MontageManifestVod;
+ weaponId: MainWeaponId | null;
+}) {
+ return (
+
+
+
+
{vod.bracketName ?? "Match"}
+
+ {vod.roundName ?? `Match ${vod.matchId}`}
+
+
+
+ {vod.teams.map((team, i) => (
+
+ {i > 0 ? vs : null}
+
+
+ {team.name}
+
+
+ ))}
+
+
+ {vod.pov ? (
+
+ ) : null}
+
+
{vod.pov ? "POV" : "Cast"}
+
+ {vod.pov?.name ?? `twitch.tv/${vod.account}`}
+
+ {vod.pov ?
: null}
+
+ {weaponId === null ? null : (
+
+
+
+ )}
+
+
+
+ );
+}
+
+function Header({ manifest }: { manifest: MontageManifest }) {
+ return (
+
+ );
+}
+
+/** A 1920×1080 frame at `scale`, the graphic centered on the export's backdrop. */
+function Frame({
+ scale,
+ children,
+}: {
+ scale: number;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+async function captureImage(
+ element: HTMLElement,
+ scale: number,
+): Promise {
+ const { snapdom } = await import("@zumer/snapdom");
+ await document.fonts.ready;
+
+ // the same options the image export captures with (ImageExportDialog.tsx)
+ return snapdom.toBlob(element, {
+ type: "png",
+ scale,
+ dpr: 1,
+ embedFonts: true,
+ compress: false,
+ reconcile: true,
+ });
+}
diff --git a/app/features/scanner/components/MontageView.module.css b/app/features/scanner/components/MontageView.module.css
new file mode 100644
index 000000000..acd33ee7e
--- /dev/null
+++ b/app/features/scanner/components/MontageView.module.css
@@ -0,0 +1,250 @@
+.view {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-4);
+}
+
+.title {
+ font-size: var(--font-sm);
+ font-weight: var(--weight-bold);
+}
+
+.note {
+ margin: 0;
+ font-size: var(--font-xs);
+ color: var(--color-text-high);
+}
+
+.error {
+ margin: 0;
+ font-size: var(--font-xs);
+ color: var(--color-error);
+}
+
+.panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+}
+
+.panelHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s-2);
+}
+
+.panelTitle {
+ font-size: var(--font-sm);
+ font-weight: var(--weight-bold);
+ font-variant-numeric: tabular-nums;
+ overflow-wrap: anywhere;
+}
+
+.criteria {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+}
+
+.criterion {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+}
+
+.criterionLabel {
+ min-width: 7rem;
+ font-size: var(--font-xs);
+ font-weight: var(--weight-semi);
+ color: var(--color-text-high);
+}
+
+.renderProgress {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ font-size: var(--font-xs);
+ font-variant-numeric: tabular-nums;
+
+ & progress {
+ flex: 1;
+ }
+}
+
+.picks {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.pick {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding: var(--s-1-5) var(--s-2);
+ border-radius: var(--radius-box);
+ background: var(--color-bg-high);
+}
+
+.pickIndex {
+ min-width: 1.5rem;
+ font-size: var(--font-xs);
+ font-weight: var(--weight-bold);
+ font-variant-numeric: tabular-nums;
+ color: var(--color-text-high);
+}
+
+.pickActions {
+ display: inline-flex;
+ margin-inline-start: auto;
+}
+
+.summary {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.summaryTitle {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+ font-size: var(--font-xs);
+ font-weight: var(--weight-bold);
+}
+
+.summaryMeta {
+ font-size: var(--font-2xs);
+ color: var(--color-text-high);
+ font-variant-numeric: tabular-nums;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
+ gap: var(--s-4);
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ border-radius: var(--radius-box);
+ background: var(--color-bg-high);
+ overflow: hidden;
+}
+
+.video,
+.thumb {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: #000 center / cover no-repeat;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ position: relative;
+
+ &:focus-visible {
+ outline: var(--focus-ring);
+ outline-offset: -3px;
+ }
+}
+
+.video {
+ cursor: default;
+}
+
+.playBadge {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+
+ &::before {
+ content: "";
+ position: absolute;
+ width: 48px;
+ height: 48px;
+ border-radius: var(--radius-full);
+ background: rgb(0 0 0 / 0.6);
+ }
+
+ & svg {
+ position: relative;
+ }
+}
+
+.cardBody {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s-2);
+ padding: var(--s-2-5) var(--s-3);
+}
+
+.group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+}
+
+.groupTitle {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1-5);
+ width: fit-content;
+ padding: 0;
+ border: none;
+ background: none;
+ color: inherit;
+ font: inherit;
+ font-size: var(--font-xs);
+ font-weight: var(--weight-bold);
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: var(--focus-ring);
+ outline-offset: 2px;
+ border-radius: var(--radius-field);
+ }
+}
+
+.groupCount {
+ color: var(--color-text-high);
+ font-variant-numeric: tabular-nums;
+}
+
+.music {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--s-2);
+ font-size: var(--font-xs);
+}
+
+.musicName {
+ overflow-wrap: anywhere;
+ font-weight: var(--weight-semi);
+}
+
+.musicButton {
+ display: inline-flex;
+ align-items: center;
+ height: var(--field-size-sm);
+ padding: 0 var(--field-padding);
+ border: var(--border-width) solid var(--color-fill-accent);
+ border-radius: var(--radius-field);
+ color: var(--color-fg-accent);
+ font-weight: var(--weight-bold);
+ cursor: pointer;
+
+ & input {
+ display: none;
+ }
+}
diff --git a/app/features/scanner/components/MontageView.tsx b/app/features/scanner/components/MontageView.tsx
new file mode 100644
index 000000000..26616c629
--- /dev/null
+++ b/app/features/scanner/components/MontageView.tsx
@@ -0,0 +1,638 @@
+/**
+ * Dev-only tournament montage builder: open a tournament's VoD folder, scan
+ * it, then pick clips one by one — the best first, a picked team's other
+ * clips hidden — and render them into one video between a title card and
+ * the top 8. The work itself lives in `montage.ts`; a reload restores the
+ * latest montage, and scanning, previews and rendering wait for its folder
+ * to be reconnected.
+ */
+import {
+ ArrowDown,
+ ArrowUp,
+ ChevronDown,
+ ChevronRight,
+ ChevronsDownUp,
+ ChevronsUpDown,
+ Film,
+ FolderOpen,
+ ImageDown,
+ Music,
+ Play,
+ Plus,
+ RefreshCw,
+ ScanSearch,
+ Square,
+ X,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { SendouButton } from "~/components/elements/Button";
+import {
+ SendouChipRadio,
+ SendouChipRadioGroup,
+} from "~/components/elements/ChipRadio";
+import { WeaponImage } from "~/components/Image";
+import { formatPosition } from "../core/format";
+import { mainWeaponLabel, modeLabel, stageLabel } from "../core/labels";
+import {
+ candidatesByBaseWeapon,
+ MONTAGE_KILL_COUNTS,
+ MONTAGE_MAX_SECONDS_OPTIONS,
+ type MontageCandidate,
+ maxSecondsFor,
+} from "../core/montage";
+import { MontageGraphicStage } from "./MontageGraphics";
+import styles from "./MontageView.module.css";
+import {
+ availableCandidates,
+ cancelMontageRender,
+ cancelMontageScan,
+ candidatePreview,
+ candidateThumbnail,
+ downloadMontageResults,
+ type MontageSnapshot,
+ montageDuration,
+ montageTeamComps,
+ movePick,
+ openMontageFolder,
+ pickCandidate,
+ reconnectMontageFolder,
+ renderMontageVideo,
+ scanMontageVods,
+ setMontageMusic,
+ unpickCandidate,
+ updateMontageCriteria,
+ useMontage,
+} from "./montage";
+import { ScanWorkers } from "./ScanWorkers";
+import { SessionHeader } from "./SessionHeader";
+import { useVodScan } from "./vod-scan";
+
+const COLLAPSED_GROUPS_STORAGE_KEY = "scanner:montage-collapsed-groups";
+
+export function MontageView() {
+ const montage = useMontage();
+ const disconnected = montage.folder !== null && !montage.folder.files;
+
+ return (
+
+
+ {disconnected && montage.folder?.directory ? (
+ }
+ onClick={() => void reconnectMontageFolder()}
+ >
+ Reconnect folder
+
+ ) : null}
+ {montage.folder?.files ? : null}
+ }
+ onClick={() => void openMontageFolder()}
+ >
+ Open folder
+
+ >
+ }
+ >
+
+ Tournament montage
+ {montage.folder ? ` · ${montage.folder.manifest.name}` : null}
+
+ {montage.folder ? null : (
+
+ Download the VoDs with pnpm vods:download <id>,
+ then open scripts/output/vods/<id>.
+
+ )}
+ {disconnected ? (
+
+ Restored from the last visit: reconnect the folder to scan, watch
+ and render clips.
+
+ ) : null}
+ {montage.folderError ? (
+ {montage.folderError}
+ ) : null}
+
+ {montage.folder ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
+
+function ResultsImageButton() {
+ const [state, setState] = useState<"idle" | "capturing" | "failed">("idle");
+
+ const download = async () => {
+ setState("capturing");
+ try {
+ await downloadMontageResults();
+ setState("idle");
+ } catch {
+ setState("failed");
+ }
+ };
+
+ return (
+ }
+ isDisabled={state === "capturing"}
+ onClick={() => void download()}
+ >
+ {state === "failed" ? "Results image failed, retry" : "Results image"}
+
+ );
+}
+
+function ScanPanel({ montage }: { montage: MontageSnapshot }) {
+ const vodScan = useVodScan();
+ const vods = montage.folder?.vods ?? [];
+ const scannedCount = vods.filter((vod) =>
+ montage.scanned.has(vod.file),
+ ).length;
+ const unscanned = vods.length - scannedCount;
+ const scan = montage.scan;
+
+ if (scan?.state === "scanning") {
+ return (
+
+
+
+ Scanning VoD {scan.index + 1}/{scan.total} · {scan.file}
+
+ }
+ onClick={cancelMontageScan}
+ >
+ Stop
+
+
+
+ {vodScan.error ? (
+ {vodScan.error}
+ ) : null}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {scannedCount}/{vods.length} VoDs scanned
+
+ {unscanned > 0 ? (
+ }
+ isDisabled={!montage.folder?.files}
+ onClick={() => void scanMontageVods()}
+ >
+ Scan {unscanned} {unscanned === 1 ? "VoD" : "VoDs"}
+
+ ) : null}
+
+ {scan?.state === "done" && scan.failed.length > 0 ? (
+ Failed to scan: {scan.failed.join(", ")}
+ ) : null}
+
+ );
+}
+
+function PicksPanel({ montage }: { montage: MontageSnapshot }) {
+ const picks = montage.picks;
+ const render = montage.render;
+ const rendering = render?.state === "rendering";
+
+ return (
+
+
+
+ Montage · {picks.length} {picks.length === 1 ? "clip" : "clips"} ·{" "}
+ {formatPosition(montageDuration(picks))}
+
+ {rendering ? (
+ }
+ onClick={cancelMontageRender}
+ >
+ Cancel
+
+ ) : (
+ }
+ isDisabled={picks.length === 0 || !montage.folder?.files}
+ onClick={() => void renderMontageVideo()}
+ >
+ Render video
+
+ )}
+
+ {rendering ? (
+
+
+
+ {formatPosition(render.done)} / {formatPosition(render.total)}
+
+
+ ) : render?.state === "done" ? (
+ render.error ? (
+ {render.error}
+ ) : (
+ Rendered.
+ )
+ ) : null}
+
+ {picks.length === 0 ? (
+
+ Pick clips below; they play in this order, between the title card and
+ the top {montage.folder?.manifest.topTeams.length}.
+
+ ) : (
+
+ {picks.map((candidate, i) => (
+ -
+ {i + 1}
+
+
+ }
+ aria-label="Move earlier"
+ isDisabled={rendering || i === 0}
+ onClick={() => movePick(candidate.key, -1)}
+ />
+ }
+ aria-label="Move later"
+ isDisabled={rendering || i === picks.length - 1}
+ onClick={() => movePick(candidate.key, 1)}
+ />
+ }
+ aria-label="Remove from montage"
+ isDisabled={rendering}
+ onClick={() => unpickCandidate(candidate.key)}
+ />
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+/** Optional: a music or video file whose sound replaces the game sound. */
+function MusicPicker({
+ music,
+ isDisabled,
+}: {
+ music: File | null;
+ isDisabled: boolean;
+}) {
+ return (
+
+ Music
+ {music ? (
+ <>
+
+ {music.name}
+ }
+ aria-label="Remove music"
+ isDisabled={isDisabled}
+ onClick={() => setMontageMusic(null)}
+ />
+ >
+ ) : (
+ <>
+
+ optional, replaces the game sound
+ >
+ )}
+
+ );
+}
+
+function CandidatesPanel({ montage }: { montage: MontageSnapshot }) {
+ const candidates = availableCandidates(montage);
+ const groups = candidatesByBaseWeapon(candidates);
+ const [collapsed, setCollapsed] = useState(loadCollapsedGroups);
+ const allCollapsed =
+ groups.length > 0 &&
+ groups.every((group) => collapsed.has(String(group.baseWeaponId)));
+
+ const toggleGroups = (groupKeys: string[], expand: boolean) => {
+ const next = new Set(collapsed);
+ for (const groupKey of groupKeys) {
+ if (expand) next.delete(groupKey);
+ else next.add(groupKey);
+ }
+ setCollapsed(next);
+ saveCollapsedGroups(next);
+ };
+
+ return (
+
+
+ Clips · {candidates.length}
+ {groups.length > 0 ? (
+ : }
+ onClick={() =>
+ toggleGroups(
+ groups.map((group) => String(group.baseWeaponId)),
+ allCollapsed,
+ )
+ }
+ >
+ {allCollapsed ? "Expand all" : "Collapse all"}
+
+ ) : null}
+
+
+
+ Min splats
+
+ {MONTAGE_KILL_COUNTS.map((kills) => (
+ updateMontageCriteria({ minKills: kills })}
+ >
+ {kills}
+
+ ))}
+
+
+ {MONTAGE_KILL_COUNTS.filter(
+ (kills) => kills >= montage.criteria.minKills,
+ ).map((kills) => (
+
+
+ {kills === MONTAGE_KILL_COUNTS.at(-1) ? `${kills}+` : kills}{" "}
+ splats, max
+
+
+ {MONTAGE_MAX_SECONDS_OPTIONS.map((seconds) => (
+
+ updateMontageCriteria({
+ maxSecondsByKills: {
+ ...montage.criteria.maxSecondsByKills,
+ [kills]: seconds,
+ },
+ })
+ }
+ >
+ {seconds}s
+
+ ))}
+
+
+ ))}
+
+ {candidates.length === 0 ? (
+
+ {montage.candidates.length === 0
+ ? "No clips yet: they appear as VoDs are scanned."
+ : "Every team with a clip is in the montage."}
+
+ ) : (
+ groups.map((group) => {
+ const groupKey = String(group.baseWeaponId);
+ const isCollapsed = collapsed.has(groupKey);
+
+ return (
+
+
+ {isCollapsed ? null : (
+
+ {group.candidates.map((candidate) => (
+
+ ))}
+
+ )}
+
+ );
+ })
+ )}
+
+ );
+}
+
+function CandidateCard({
+ candidate,
+ isDisabled,
+}: {
+ candidate: MontageCandidate;
+ isDisabled: boolean;
+}) {
+ const thumbnail = useThumbnail(candidate);
+ const [previewUrl, setPreviewUrl] = useState(null);
+
+ // the preview's URL is released with the card
+ useEffect(
+ () => () => {
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
+ },
+ [previewUrl],
+ );
+
+ const play = async () => {
+ const blob = await candidatePreview(candidate);
+ if (blob) setPreviewUrl(URL.createObjectURL(blob));
+ };
+
+ return (
+
+ {previewUrl ? (
+ // biome-ignore lint/a11y/useMediaCaption: game footage has no captions
+
+ ) : (
+
+ )}
+
+
+ }
+ isDisabled={isDisabled}
+ onClick={() => pickCandidate(candidate)}
+ >
+ Add
+
+
+
+ );
+}
+
+function CandidateSummary({ candidate }: { candidate: MontageCandidate }) {
+ const { window, vod } = candidate;
+ const where = [modeLabel(candidate.mode), stageLabel(candidate.stage)]
+ .filter(Boolean)
+ .join(" · ");
+
+ return (
+
+
+ {candidate.weaponId === null ||
+ candidate.weaponId === undefined ? null : (
+
+ )}
+ {window.kills} splats · {vod.team?.name ?? "Cast"}
+
+
+ {formatPosition(candidate.duration)}
+ {candidate.duration < window.end - window.start
+ ? ` (${formatPosition(window.end - window.start)} raw)`
+ : null}
+ {where ? ` · ${where}` : null} · {vod.account} · match {vod.matchId}
+
+
+ );
+}
+
+/** Base weapon ids (as strings, "null" for unknown) of the collapsed clip groups. */
+function loadCollapsedGroups(): Set {
+ try {
+ const stored = JSON.parse(
+ localStorage.getItem(COLLAPSED_GROUPS_STORAGE_KEY) ?? "[]",
+ );
+ return new Set(Array.isArray(stored) ? stored.map(String) : []);
+ } catch {
+ return new Set();
+ }
+}
+
+function saveCollapsedGroups(collapsed: Set): void {
+ try {
+ localStorage.setItem(
+ COLLAPSED_GROUPS_STORAGE_KEY,
+ JSON.stringify([...collapsed]),
+ );
+ } catch {
+ // private mode or a full quota: the groups stay collapsed for this visit
+ }
+}
+
+/** The candidate's thumbnail once decoded (they queue up, one VoD open at a time). */
+function useThumbnail(candidate: MontageCandidate): string | null {
+ const [thumbnail, setThumbnail] = useState(null);
+
+ // decoding a frame is async work outside React
+ useEffect(() => {
+ let stale = false;
+ void candidateThumbnail(candidate).then((next) => {
+ if (!stale) setThumbnail(next);
+ });
+ return () => {
+ stale = true;
+ };
+ }, [candidate]);
+
+ return thumbnail;
+}
diff --git a/app/features/scanner/components/ScannerApp.tsx b/app/features/scanner/components/ScannerApp.tsx
index f65e5e7f3..e54568455 100644
--- a/app/features/scanner/components/ScannerApp.tsx
+++ b/app/features/scanner/components/ScannerApp.tsx
@@ -16,6 +16,8 @@ import { FixturesPage } from "./FixturesPage";
import { LandingView } from "./LandingView";
import { LiveView } from "./LiveView";
import { useLiveSession } from "./live-session";
+import { MontageView } from "./MontageView";
+import { cancelMontageScan } from "./montage";
import { PastSessionView } from "./PastSessionView";
import styles from "./ScannerApp.module.css";
import { ScreenshotPage } from "./ScreenshotPage";
@@ -50,7 +52,13 @@ export function ScannerApp() {
}, []);
// a file scan has no Cancel button: leaving the page is how it is stopped
- useEffect(() => cancelVodScan, []);
+ useEffect(
+ () => () => {
+ cancelMontageScan();
+ cancelVodScan();
+ },
+ [],
+ );
const capturing = live.status === "running" || live.status === "starting";
@@ -67,6 +75,8 @@ export function ScannerApp() {
) : view === "fixtures" && process.env.NODE_ENV === "development" ? (
+ ) : view === "montage" && process.env.NODE_ENV === "development" ? (
+
) : (
);
diff --git a/app/features/scanner/components/SettingsPopover.tsx b/app/features/scanner/components/SettingsPopover.tsx
index a15a434af..a2a130381 100644
--- a/app/features/scanner/components/SettingsPopover.tsx
+++ b/app/features/scanner/components/SettingsPopover.tsx
@@ -1,7 +1,7 @@
/**
* The settings popover, opened from ⚙ on the landing and the live header:
* the upload, clip and GPU toggles, the retention notes, and the debug tools
- * (enabling debug mode, saving the live frame, the fixtures link in development). The source lives on the landing's Live card, the one place
+ * (enabling debug mode, saving the live frame, the fixtures and montage links in development). The source lives on the landing's Live card, the one place
* it must be right.
*/
import { Bug, Camera, Settings } from "lucide-react";
@@ -41,7 +41,7 @@ export function SettingsPopover({
const loggedIn = isLoggedIn();
const debug = useDebug();
const [, setDebugParam] = useSearchParam(scannerSearchParams, "debug");
- const showFixturesLink = process.env.NODE_ENV === "development";
+ const showDevLinks = process.env.NODE_ENV === "development";
const showSaveFrame = debug && onSaveFrame !== undefined;
const gpuSupported = "gpu" in navigator;
@@ -142,7 +142,7 @@ export function SettingsPopover({
Sessions: last 30 days or {MAX_SESSIONS} sessions.
- {!debug || showSaveFrame || showFixturesLink ? (
+ {!debug || showSaveFrame || showDevLinks ? (
Debug
@@ -166,15 +166,25 @@ export function SettingsPopover({
Save frame as fixture
) : null}
- {showFixturesLink ? (
-
- Fixtures
-
+ {showDevLinks ? (
+ <>
+
+ Fixtures
+
+
+ Tournament montage
+
+ >
) : null}
diff --git a/app/features/scanner/components/montage-comps.ts b/app/features/scanner/components/montage-comps.ts
new file mode 100644
index 000000000..58dee7459
--- /dev/null
+++ b/app/features/scanner/components/montage-comps.ts
@@ -0,0 +1,63 @@
+/**
+ * Team comps from the montage's own scans: every scoreboard a VoD read names
+ * both sides' weapons, and the POV seat tells which side is the streamer's
+ * team. The comp is built as the results image export builds it
+ * (`RunComps`).
+ */
+import * as RunComps from "~/features/img-export/core/RunComps";
+import type { MainWeaponId } from "~/modules/in-game-lists/types";
+import type {
+ MontageGame,
+ MontageLineupPlayer,
+ MontageManifestVod,
+} from "../core/montage";
+
+const COMP_SIZE = 4;
+
+/**
+ * Comps by team id; a team is only in it with a full comp. `games` are by
+ * VoD file name, in play order within a VoD.
+ */
+export function scannedTeamComps(
+ vods: readonly MontageManifestVod[],
+ games: ReadonlyMap,
+): Map {
+ const observations = new Map();
+ const observe = (
+ teamId: number,
+ players: readonly MontageLineupPlayer[],
+ mapOrder: number,
+ ) => {
+ const teamObservations = observations.get(teamId) ?? [];
+ for (const [slot, player] of players.entries()) {
+ if (player.weaponId === null) continue;
+ teamObservations.push({
+ playerKey: player.name ?? `slot-${slot}`,
+ weaponSplId: player.weaponId,
+ mapOrder,
+ });
+ }
+ observations.set(teamId, teamObservations);
+ };
+
+ for (const vod of vods) {
+ const povTeamId = vod.team?.id;
+ if (povTeamId === undefined) continue;
+ const opponentId = vod.teams.find((team) => team.id !== povTeamId)?.id;
+ for (const [index, game] of (games.get(vod.file) ?? []).entries()) {
+ if (!game.lineups) continue;
+ const mapOrder = vod.matchId * 100 + index;
+ observe(povTeamId, game.lineups.pov, mapOrder);
+ if (opponentId !== undefined) {
+ observe(opponentId, game.lineups.opponent, mapOrder);
+ }
+ }
+ }
+
+ const comps = new Map();
+ for (const [teamId, teamObservations] of observations) {
+ const comp = RunComps.buildComp(teamObservations);
+ if (comp.length >= COMP_SIZE) comps.set(teamId, comp);
+ }
+ return comps;
+}
diff --git a/app/features/scanner/components/montage.ts b/app/features/scanner/components/montage.ts
new file mode 100644
index 000000000..e7d423b9d
--- /dev/null
+++ b/app/features/scanner/components/montage.ts
@@ -0,0 +1,693 @@
+/**
+ * The tournament montage (dev only, `view=montage`), a module singleton like
+ * the VoD scan it drives. Opening a folder written by `pnpm vods:download`
+ * (the File System Access API's directory picker) reads its manifest; every
+ * VoD in it is scanned in turn through `startVodScan` (saved like any file
+ * scan, no clips cut) and its streaks meeting the criteria (`montageWindows`:
+ * min splats and a length cap per splat count, kept in localStorage) become
+ * candidates. Picking one hides the rest of that team's candidates — a cast
+ * has no team, so only its own VoD's go. The scanned games, the picks and
+ * the folder's handle live in IndexedDB (`store/montages.ts`): a reload
+ * restores the latest montage at once, and the folder reconnects on its own
+ * when the browser kept the permission, else with one click. Rendering
+ * stitches the picks between the title and standings cards
+ * (`capture/montage-render.ts`); the cards and each clip's ribbon are drawn
+ * by the page (`MontageGraphics.tsx`), which registers how through
+ * `setMontageGraphicCapture`. An optional music file replaces the game
+ * sound for the whole video.
+ */
+import { useSyncExternalStore } from "react";
+import type { MainWeaponId } from "~/modules/in-game-lists/types";
+import {
+ type MontageClipSource,
+ type MontageRenderProgress,
+ renderMontage,
+} from "../capture/montage-render";
+import { extractVodClip, vodFrameThumbnail } from "../capture/vod-clips";
+import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
+import { buildScannerMatches } from "../core/match-builder";
+import {
+ DEFAULT_MONTAGE_CRITERIA,
+ MONTAGE_MANIFEST_FILE,
+ type MontageCandidate,
+ type MontageCriteria,
+ type MontageGame,
+ type MontageManifest,
+ type MontageManifestVod,
+ montageWindows,
+ playbackDuration,
+ playbackSegments,
+} from "../core/montage";
+import type { ScannerMatch } from "../core/scanner-match";
+import {
+ latestMontage,
+ loadMontage,
+ putMontage,
+ type StoredMontage,
+} from "../store/montages";
+import { listVods, loadVod, loadVodEvents } from "../store/vods";
+import { downloadBlob } from "./download";
+import { describeError } from "./errors";
+import { scannedTeamComps } from "./montage-comps";
+import { cancelVodScan, startVodScan } from "./vod-scan";
+
+const CRITERIA_STORAGE_KEY = "scanner:montage-criteria";
+
+export interface MontageFolder {
+ manifest: MontageManifest;
+ /** the manifest's VoDs found in the folder */
+ vods: MontageManifestVod[];
+ /** null while the folder isn't connected this visit (restored from the store) */
+ files: Map | null;
+ directory: FileSystemDirectoryHandle | null;
+}
+
+export type MontageScan =
+ | { state: "scanning"; file: string; index: number; total: number }
+ | { state: "done"; failed: string[] };
+
+/** What the page draws for the video; see `MontageGraphics.tsx`. */
+export type MontageGraphic =
+ | { kind: "title" }
+ | { kind: "standings" }
+ /** the results image on its own: every top team's roster and comp */
+ | { kind: "results" }
+ | {
+ kind: "ribbon";
+ vod: MontageManifestVod;
+ weaponId: MainWeaponId | null;
+ };
+
+/** resolves to the graphic as a PNG */
+type GraphicCapture = (graphic: MontageGraphic) => Promise;
+
+export type MontageRender =
+ | ({ state: "rendering" } & MontageRenderProgress)
+ | { state: "done"; error: string | null };
+
+export interface MontageSnapshot {
+ folder: MontageFolder | null;
+ folderError: string | null;
+ /** the games of the folder's scanned VoDs, by file name */
+ scanned: ReadonlyMap;
+ criteria: MontageCriteria;
+ /** every candidate meeting the criteria, best first */
+ candidates: MontageCandidate[];
+ /** in montage order */
+ picks: MontageCandidate[];
+ /** plays under the whole video instead of the game sound */
+ music: File | null;
+ scan: MontageScan | null;
+ render: MontageRender | null;
+}
+
+type DirectoryPicker = (options: {
+ id: string;
+ mode: "read";
+}) => Promise;
+
+/** The permission calls Chromium has on handles but TypeScript's DOM lib lacks. */
+type PermissionedDirectory = FileSystemDirectoryHandle & {
+ queryPermission(descriptor: { mode: "read" }): Promise;
+ requestPermission(descriptor: { mode: "read" }): Promise;
+};
+
+type SaveFilePicker = (options: {
+ suggestedName: string;
+ types: Array<{ description: string; accept: Record }>;
+}) => Promise;
+
+const EMPTY: MontageSnapshot = {
+ folder: null,
+ folderError: null,
+ scanned: new Map(),
+ criteria: DEFAULT_MONTAGE_CRITERIA,
+ candidates: [],
+ picks: [],
+ music: null,
+ scan: null,
+ render: null,
+};
+
+let snapshot = EMPTY;
+const listeners = new Set<() => void>();
+let restoreStarted = false;
+let scanAbort = { aborted: false };
+let renderAbort: AbortController | null = null;
+let captureGraphic: GraphicCapture | null = null;
+/** object URLs of the folder's images, released when another folder connects */
+let imageUrls = new Map();
+const thumbnails = new Map>();
+/** one thumbnail decode at a time: a grid of cards would otherwise open every VoD at once */
+let thumbnailQueue: Promise = Promise.resolve();
+
+/** The montage; the latest one is restored from the store on first use. */
+export function useMontage(): MontageSnapshot {
+ return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY);
+}
+
+/** Candidates still pickable: none from a team (or a cast VoD) already picked. */
+export function availableCandidates(state: MontageSnapshot) {
+ const pickedTeams = new Set(
+ state.picks.map((candidate) => teamKey(candidate.vod)),
+ );
+ return state.candidates.filter(
+ (candidate) => !pickedTeams.has(teamKey(candidate.vod)),
+ );
+}
+
+/** Opens a folder written by `pnpm vods:download` through the directory picker. */
+export async function openMontageFolder(): Promise {
+ const picker = (window as Window & { showDirectoryPicker?: DirectoryPicker })
+ .showDirectoryPicker;
+ if (!picker) {
+ set({ folderError: "Opening a folder needs Chrome" });
+ return;
+ }
+ let directory: FileSystemDirectoryHandle;
+ try {
+ directory = await picker({ id: "scanner-montage", mode: "read" });
+ } catch {
+ // the picker was dismissed
+ return;
+ }
+ await connect(directory);
+}
+
+/** Asks the browser again for the restored folder (needs the click's gesture). */
+export async function reconnectMontageFolder(): Promise {
+ const directory = snapshot.folder?.directory as
+ | PermissionedDirectory
+ | null
+ | undefined;
+ if (!directory) return;
+ if ((await directory.requestPermission({ mode: "read" })) !== "granted") {
+ return;
+ }
+ await connect(directory);
+}
+
+/** Scans the folder's VoDs that have no games yet, one after another. */
+export async function scanMontageVods(): Promise {
+ const folder = snapshot.folder;
+ const files = folder?.files;
+ if (!folder || !files || snapshot.scan?.state === "scanning") return;
+ const abort = { aborted: false };
+ scanAbort = abort;
+ const queue = folder.vods.filter((vod) => !snapshot.scanned.has(vod.file));
+ const failed: string[] = [];
+ for (const [index, vod] of queue.entries()) {
+ set({
+ scan: { state: "scanning", file: vod.file, index, total: queue.length },
+ });
+ await startVodScan(files.get(vod.file)!, {
+ telemetry: false,
+ saveFrames: false,
+ clips: false,
+ });
+ if (abort.aborted || snapshot.folder !== folder) return;
+ if (!(await loadVod(vod.file))) {
+ failed.push(vod.file);
+ continue;
+ }
+ const games = await scannedGames(vod);
+ set({ scanned: new Map([...snapshot.scanned, [vod.file, games]]) });
+ persist();
+ }
+ set({ scan: { state: "done", failed } });
+}
+
+/** Stops the folder scan; the VoD being scanned is not saved. */
+export function cancelMontageScan(): void {
+ if (snapshot.scan?.state !== "scanning") return;
+ scanAbort.aborted = true;
+ cancelVodScan();
+ set({ scan: null });
+}
+
+/** Changes what makes a clip candidate; picks already made stay as they are. */
+export function updateMontageCriteria(patch: Partial): void {
+ const criteria = { ...snapshot.criteria, ...patch };
+ set({ criteria });
+ try {
+ localStorage.setItem(CRITERIA_STORAGE_KEY, JSON.stringify(criteria));
+ } catch {
+ // private mode or a full quota: the criteria still hold for this visit
+ }
+}
+
+/** Sets or clears the music that replaces the game sound. */
+export function setMontageMusic(music: File | null): void {
+ set({ music });
+ persist();
+}
+
+export function pickCandidate(candidate: MontageCandidate): void {
+ setPicks([...snapshot.picks, candidate]);
+}
+
+export function unpickCandidate(key: string): void {
+ setPicks(snapshot.picks.filter((pick) => pick.key !== key));
+}
+
+/** Moves a pick `delta` places, clamped to the list. */
+export function movePick(key: string, delta: number): void {
+ const index = snapshot.picks.findIndex((pick) => pick.key === key);
+ if (index === -1) return;
+ const picks = snapshot.picks.toSpliced(index, 1);
+ picks.splice(
+ Math.max(0, Math.min(picks.length, index + delta)),
+ 0,
+ snapshot.picks[index]!,
+ );
+ setPicks(picks);
+}
+
+/** A small JPEG data URL of the candidate's last kill; decoded once, one at a time. */
+export function candidateThumbnail(
+ candidate: MontageCandidate,
+): Promise {
+ const file = snapshot.folder?.files?.get(candidate.vod.file);
+ if (!file) return Promise.resolve(null);
+ let thumbnail = thumbnails.get(candidate.key);
+ if (!thumbnail) {
+ thumbnail = thumbnailQueue.then(() =>
+ vodFrameThumbnail(file, candidate.window.t),
+ );
+ thumbnailQueue = thumbnail;
+ thumbnails.set(candidate.key, thumbnail);
+ }
+ return thumbnail;
+}
+
+/** The candidate's footage as it is in the VoD (no fast-forward), for watching before picking. */
+export async function candidatePreview(
+ candidate: MontageCandidate,
+): Promise {
+ const file = snapshot.folder?.files?.get(candidate.vod.file);
+ if (!file) return null;
+ const clip = await extractVodClip(file, {
+ start: candidate.window.start,
+ end: candidate.window.end,
+ maxSeconds: candidate.window.end - candidate.window.start,
+ });
+ return clip.blob;
+}
+
+/**
+ * Renders the picks into an MP4, saved where the user picks (Chrome's save
+ * dialog, opened first so the click still counts as the user's gesture) or,
+ * without one, downloaded once done.
+ */
+export async function renderMontageVideo(): Promise {
+ const folder = snapshot.folder;
+ const files = folder?.files;
+ const picks = snapshot.picks;
+ if (
+ !folder ||
+ !files ||
+ picks.length === 0 ||
+ snapshot.render?.state === "rendering"
+ ) {
+ return;
+ }
+ const { manifest } = folder;
+ const fileName = `${fileSlug(manifest)}-montage.mp4`;
+
+ let writable: FileSystemWritableFileStream | null;
+ try {
+ writable = await openSaveFile(fileName);
+ } catch {
+ // the save dialog was dismissed
+ return;
+ }
+ const controller = new AbortController();
+ renderAbort = controller;
+ set({
+ render: {
+ state: "rendering",
+ done: 0,
+ total: montageDuration(picks),
+ },
+ });
+ try {
+ const captureBlob = captureGraphic;
+ if (!captureBlob) throw new Error("the montage view isn't mounted");
+ const capture = async (graphic: MontageGraphic) =>
+ createImageBitmap(await captureBlob(graphic));
+ const title = await capture({ kind: "title" });
+ const standings = await capture({ kind: "standings" });
+ const clips: MontageClipSource[] = [];
+ for (const candidate of picks) {
+ const file = files.get(candidate.vod.file);
+ if (!file) {
+ throw new Error(`${candidate.vod.file} is not in the folder`);
+ }
+ clips.push({
+ file,
+ segments: playbackSegments(candidate.window),
+ ribbon: await capture({
+ kind: "ribbon",
+ vod: candidate.vod,
+ weaponId: candidate.weaponId,
+ }),
+ });
+ }
+ const blob = await renderMontage({
+ title,
+ clips,
+ standings,
+ music: snapshot.music,
+ writable,
+ signal: controller.signal,
+ onProgress: (progress) =>
+ set({ render: { state: "rendering", ...progress } }),
+ });
+ if (blob) downloadBlob(fileName, blob);
+ set({ render: { state: "done", error: null } });
+ } catch (error) {
+ set({
+ render: {
+ state: "done",
+ error: controller.signal.aborted
+ ? "Rendering was cancelled"
+ : describeError(error),
+ },
+ });
+ } finally {
+ renderAbort = null;
+ }
+}
+
+/** Set by the mounted montage view: how a graphic becomes a video frame image. */
+export function setMontageGraphicCapture(capture: GraphicCapture | null): void {
+ captureGraphic = capture;
+}
+
+/** An image of the connected folder as a URL the graphics can show. */
+export function montageImageUrl(name: string | null): string | undefined {
+ const file = name ? snapshot.folder?.files?.get(name) : undefined;
+ if (!file) return undefined;
+ let url = imageUrls.get(file);
+ if (!url) {
+ url = URL.createObjectURL(file);
+ imageUrls.set(file, url);
+ }
+ return url;
+}
+
+/** Downloads the results image (every top team's roster and comp) as a PNG. */
+export async function downloadMontageResults(): Promise {
+ const folder = snapshot.folder;
+ if (!folder?.files) throw new Error("reconnect the folder first");
+ if (!captureGraphic) throw new Error("the montage view isn't mounted");
+ downloadBlob(
+ `${fileSlug(folder.manifest)}-results.png`,
+ await captureGraphic({ kind: "results" }),
+ );
+}
+
+export function cancelMontageRender(): void {
+ renderAbort?.abort();
+}
+
+/** The top teams' comps as the scans saw them, by team id. */
+export function montageTeamComps(state: MontageSnapshot) {
+ return state.folder
+ ? scannedTeamComps(state.folder.vods, state.scanned)
+ : new Map();
+}
+
+/** Seconds of montage the clips make, fast-forwarded (cards excluded). */
+export function montageDuration(candidates: readonly MontageCandidate[]) {
+ return candidates.reduce((sum, candidate) => sum + candidate.duration, 0);
+}
+
+function getSnapshot(): MontageSnapshot {
+ if (!restoreStarted) {
+ restoreStarted = true;
+ void restore();
+ }
+ return snapshot;
+}
+
+/** The latest montage as stored, its folder connected when the browser still grants it. */
+async function restore(): Promise {
+ const criteria = loadCriteria();
+ const stored = await latestMontage().catch(() => undefined);
+ if (!stored || snapshot.folder) {
+ set({ criteria });
+ return;
+ }
+ set({
+ ...EMPTY,
+ criteria,
+ folder: {
+ manifest: stored.manifest,
+ vods: stored.vods,
+ files: null,
+ directory: stored.directory,
+ },
+ scanned: new Map(Object.entries(stored.games)),
+ music: stored.music ?? null,
+ });
+ set({ picks: currentPicks(stored.picks) });
+ const directory = stored.directory as PermissionedDirectory | null;
+ if (
+ directory &&
+ (await directory.queryPermission({ mode: "read" })) === "granted"
+ ) {
+ await connect(directory);
+ }
+}
+
+/**
+ * Reads the folder's manifest and files, then its stored montage: games of
+ * VoDs saved by a scan but missing from the store (scanned from the VoD
+ * view, say) are built from their events.
+ */
+async function connect(directory: FileSystemDirectoryHandle): Promise {
+ cancelMontageScan();
+ for (const url of imageUrls.values()) URL.revokeObjectURL(url);
+ imageUrls = new Map();
+ try {
+ const { manifest, files } = await readFolder(directory);
+ const vods = manifest.vods.filter((vod) => files.has(vod.file));
+ const stored = await loadMontage(manifest.tournamentId);
+ const scanned = new Map(Object.entries(stored?.games ?? {}));
+ const saved = new Set((await listVods()).map((vod) => vod.name));
+ for (const vod of vods) {
+ // games stored before the POV weapon and lineups were kept are built again
+ const games = scanned.get(vod.file);
+ const stale = !games || games.some((game) => !("lineups" in game));
+ if (stale && saved.has(vod.file)) {
+ scanned.set(vod.file, await scannedGames(vod));
+ }
+ }
+ set({
+ ...EMPTY,
+ criteria: snapshot.criteria,
+ folder: { manifest, vods, files, directory },
+ scanned,
+ music: stored?.music ?? null,
+ });
+ set({ picks: currentPicks(stored?.picks ?? []) });
+ persist();
+ } catch (error) {
+ set({ folderError: describeError(error) });
+ }
+}
+
+async function readFolder(directory: FileSystemDirectoryHandle) {
+ const read = async (name: string) => {
+ try {
+ return await (await directory.getFileHandle(name)).getFile();
+ } catch {
+ return null;
+ }
+ };
+ const manifestFile = await read(MONTAGE_MANIFEST_FILE);
+ if (!manifestFile) {
+ throw new Error(
+ `No ${MONTAGE_MANIFEST_FILE} in the folder: pick a folder written by pnpm vods:download`,
+ );
+ }
+ const manifest = JSON.parse(await manifestFile.text()) as MontageManifest;
+ if (manifest.vods.some((vod) => !vod.teams)) {
+ throw new Error(
+ `${MONTAGE_MANIFEST_FILE} is from an older version: run pnpm vods:download ${manifest.tournamentId} again`,
+ );
+ }
+ const names = new Set(
+ [
+ manifest.logo,
+ manifest.organization?.logo ?? null,
+ ...manifest.topTeams.map((team) => team.logo),
+ ...manifest.vods.flatMap((vod) => [
+ vod.file,
+ vod.pov?.avatar ?? null,
+ ...vod.teams.map((team) => team.logo),
+ ]),
+ ].filter((name) => name !== null),
+ );
+ const files = new Map();
+ for (const name of names) {
+ const file = await read(name);
+ if (file) files.set(name, file);
+ }
+ return { manifest, files };
+}
+
+async function scannedGames(vod: MontageManifestVod): Promise {
+ const events = await loadVodEvents(vod.file);
+ return buildScannerMatches(events).map(({ match, sources }) => ({
+ kills: match.kills,
+ mode: match.mode,
+ stage: match.stage,
+ povWeaponId: match.pov
+ ? (match.teams[match.pov.team].players[match.pov.index]?.weaponId ?? null)
+ : null,
+ lineups: match.pov
+ ? {
+ pov: lineupOf(match.teams[match.pov.team]),
+ opponent: lineupOf(match.teams[match.pov.team === 0 ? 1 : 0]),
+ }
+ : null,
+ deaths: sources
+ .filter((event) => event.type === DEATH_EVENT_TYPE)
+ .map((event) => event.t),
+ }));
+}
+
+/** Every streak of the scanned VoDs that meets the criteria, best first. */
+function candidatesOf(
+ folder: MontageFolder | null,
+ scanned: MontageSnapshot["scanned"],
+ criteria: MontageCriteria,
+): MontageCandidate[] {
+ return (folder?.vods ?? [])
+ .flatMap((vod) =>
+ (scanned.get(vod.file) ?? []).flatMap((game) =>
+ montageWindows(game, criteria).map((window) => ({
+ key: `${vod.file}@${window.start}-${window.end}`,
+ vod,
+ window,
+ mode: game.mode,
+ stage: game.stage,
+ weaponId: game.povWeaponId,
+ duration: playbackDuration(playbackSegments(window)),
+ })),
+ ),
+ )
+ .sort((a, b) => b.window.score - a.window.score);
+}
+
+function lineupOf(team: ScannerMatch["teams"][number]) {
+ return team.players.map((player) => ({
+ name: player.name,
+ weaponId: player.weaponId,
+ }));
+}
+
+/**
+ * Picks point at the folder's current manifest entry and candidate: a pick
+ * is stored whole, so one made by an older version carries that version's
+ * shape. Picks of VoDs no longer in the folder go.
+ */
+function currentPicks(picks: readonly MontageCandidate[]): MontageCandidate[] {
+ const vodByFile = new Map(
+ (snapshot.folder?.vods ?? []).map((vod) => [vod.file, vod]),
+ );
+ const candidateByKey = new Map(
+ snapshot.candidates.map((candidate) => [candidate.key, candidate]),
+ );
+ return picks.flatMap((pick) => {
+ const vod = vodByFile.get(pick.vod.file);
+ if (!vod) return [];
+ return [
+ candidateByKey.get(pick.key) ?? {
+ ...pick,
+ vod,
+ weaponId: pick.weaponId ?? null,
+ },
+ ];
+ });
+}
+
+function fileSlug(manifest: MontageManifest): string {
+ return manifest.name.replace(/[^\p{L}\p{N}]+/gu, "-");
+}
+
+function teamKey(vod: MontageManifestVod): string {
+ return vod.team ? `team:${vod.team.id}` : `vod:${vod.file}`;
+}
+
+function setPicks(picks: MontageCandidate[]): void {
+ set({ picks });
+ persist();
+}
+
+/** Writes the montage to the store; a failed write only costs the next reload its restore. */
+function persist(): void {
+ const folder = snapshot.folder;
+ if (!folder) return;
+ const montage: StoredMontage = {
+ tournamentId: folder.manifest.tournamentId,
+ manifest: folder.manifest,
+ vods: folder.vods,
+ games: Object.fromEntries(snapshot.scanned),
+ picks: snapshot.picks,
+ music: snapshot.music,
+ directory: folder.directory,
+ openedAt: Date.now(),
+ };
+ void putMontage(montage).catch(() => {});
+}
+
+function loadCriteria(): MontageCriteria {
+ try {
+ const stored: Partial = JSON.parse(
+ localStorage.getItem(CRITERIA_STORAGE_KEY) ?? "{}",
+ );
+ return {
+ minKills: stored.minKills ?? DEFAULT_MONTAGE_CRITERIA.minKills,
+ maxSecondsByKills: {
+ ...DEFAULT_MONTAGE_CRITERIA.maxSecondsByKills,
+ ...stored.maxSecondsByKills,
+ },
+ };
+ } catch {
+ return DEFAULT_MONTAGE_CRITERIA;
+ }
+}
+
+/** A writable for a file the user picks; null when the browser has no save dialog (the render then downloads). */
+async function openSaveFile(
+ suggestedName: string,
+): Promise {
+ const picker = (window as Window & { showSaveFilePicker?: SaveFilePicker })
+ .showSaveFilePicker;
+ if (!picker) return null;
+ const handle = await picker({
+ suggestedName,
+ types: [{ description: "MP4 video", accept: { "video/mp4": [".mp4"] } }],
+ });
+ return handle.createWritable();
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function set(patch: Partial): void {
+ snapshot = { ...snapshot, ...patch };
+ if (patch.folder || patch.scanned || patch.criteria) {
+ snapshot.candidates = candidatesOf(
+ snapshot.folder,
+ snapshot.scanned,
+ snapshot.criteria,
+ );
+ }
+ for (const listener of listeners) listener();
+}
diff --git a/app/features/scanner/components/vod-scan.ts b/app/features/scanner/components/vod-scan.ts
index cbb85fd6f..451a3f4d5 100644
--- a/app/features/scanner/components/vod-scan.ts
+++ b/app/features/scanner/components/vod-scan.ts
@@ -202,11 +202,16 @@ export function cancelVodScan(): void {
/**
* Scans `file` as fast as decoding allows; a finished scan replaces any saved
* one of the same name. `saveFrames` (debug mode) keeps each event's analyzed
- * frame and thumbnail for the raw detections.
+ * frame and thumbnail for the raw detections; `clips: false` skips cutting
+ * clips of the scan.
*/
export async function startVodScan(
file: File,
- { telemetry, saveFrames }: { telemetry: boolean; saveFrames: boolean },
+ {
+ telemetry,
+ saveFrames,
+ clips = true,
+ }: { telemetry: boolean; saveFrames: boolean; clips?: boolean },
): Promise {
cancelVodScan();
const abort = { aborted: false };
@@ -487,7 +492,7 @@ export async function startVodScan(
events = (await loadVodEvents(file.name)).map(toScanEvent);
update({ events, status: "done" });
void refreshVods();
- await cutClips(file, events, update);
+ if (clips) await cutClips(file, events, update);
}
}
diff --git a/app/features/scanner/core/clips/scoring.ts b/app/features/scanner/core/clips/scoring.ts
index af841a03f..8b0685dc2 100644
--- a/app/features/scanner/core/clips/scoring.ts
+++ b/app/features/scanner/core/clips/scoring.ts
@@ -29,6 +29,8 @@ export interface ClipWindow {
/** that kill's match timer reading, when read */
time: number | null;
kills: number;
+ /** seconds into the stream/file of each kill, chronological */
+ killTimes: number[];
score: number;
}
@@ -77,6 +79,7 @@ function scoreRun(kills: readonly ScannerMatchKill[]): ClipWindow {
t: last.t,
time: last.time,
kills: kills.length,
+ killTimes: kills.map((kill) => kill.t),
score: kills.length ** 2 + kills.length / span,
};
}
diff --git a/app/features/scanner/core/loudness.ts b/app/features/scanner/core/loudness.ts
new file mode 100644
index 000000000..432efae3e
--- /dev/null
+++ b/app/features/scanner/core/loudness.ts
@@ -0,0 +1,210 @@
+/**
+ * Integrated loudness per ITU-R BS.1770-4 / EBU R128, in LUFS: K-weighting
+ * (a high shelf, then a high pass), mean square over 400 ms blocks every
+ * 100 ms, the absolute gate at -70 LUFS and the relative gate 10 LU under
+ * the absolutely gated loudness. Channels weigh 1 each, as left and right do
+ * in the standard. The filter coefficients are derived for any sample rate
+ * (the standard tabulates them for 48 kHz only). Plus the look-ahead peak
+ * limiter that lets quiet footage with sharp peaks be raised to a target.
+ */
+
+const BLOCK_SECONDS = 0.4;
+const BLOCK_STEP_SECONDS = 0.1;
+const ABSOLUTE_GATE_LUFS = -70;
+const RELATIVE_GATE_LU = 10;
+const LOUDNESS_OFFSET = -0.691;
+/** the limiter sees a peak coming this early and ramps down before it */
+const LIMITER_LOOKAHEAD_SECONDS = 0.005;
+const LIMITER_ATTACK_SECONDS = 0.0015;
+const LIMITER_RELEASE_SECONDS = 0.15;
+
+interface Biquad {
+ b: [number, number, number];
+ a: [number, number];
+}
+
+/** Integrated loudness in LUFS; -Infinity for silence (or audio shorter than one block). */
+export function integratedLoudness(
+ channels: readonly Float32Array[],
+ sampleRate: number,
+): number {
+ const blockLength = Math.round(BLOCK_SECONDS * sampleRate);
+ const step = Math.round(BLOCK_STEP_SECONDS * sampleRate);
+ const length = channels[0]?.length ?? 0;
+ if (length < blockLength) return Number.NEGATIVE_INFINITY;
+
+ const filters = kWeighting(sampleRate);
+ const squaredPrefixSums = channels.map((channel) => {
+ const weighted = filters.reduce(applyBiquad, channel);
+ const sums = new Float64Array(length + 1);
+ for (let i = 0; i < length; i++) {
+ sums[i + 1] = sums[i]! + weighted[i]! ** 2;
+ }
+ return sums;
+ });
+
+ const blocks: number[][] = [];
+ for (let start = 0; start + blockLength <= length; start += step) {
+ blocks.push(
+ squaredPrefixSums.map(
+ (sums) => (sums[start + blockLength]! - sums[start]!) / blockLength,
+ ),
+ );
+ }
+
+ const aboveAbsolute = blocks.filter(
+ (block) => blockLoudness(block) > ABSOLUTE_GATE_LUFS,
+ );
+ if (aboveAbsolute.length === 0) return Number.NEGATIVE_INFINITY;
+ const relativeGate =
+ gatedLoudness(aboveAbsolute, channels.length) - RELATIVE_GATE_LU;
+ const gated = aboveAbsolute.filter(
+ (block) => blockLoudness(block) > relativeGate,
+ );
+
+ return gatedLoudness(gated, channels.length);
+}
+
+/**
+ * Keeps every sample within ±`ceiling` in place, the channels sharing one
+ * gain: it dips smoothly just before a peak and recovers after, so the
+ * signal around the peak isn't clipped flat. Samples the smoothing still
+ * leaves over the ceiling are clamped.
+ */
+export function limitPeaks(
+ channels: readonly Float32Array[],
+ sampleRate: number,
+ ceiling: number,
+): void {
+ const length = channels[0]?.length ?? 0;
+ const needed = new Float32Array(length);
+ for (let i = 0; i < length; i++) {
+ let peak = 0;
+ for (const channel of channels)
+ peak = Math.max(peak, Math.abs(channel[i]!));
+ needed[i] = peak > ceiling ? ceiling / peak : 1;
+ }
+ const ahead = slidingMinimum(
+ needed,
+ Math.round(LIMITER_LOOKAHEAD_SECONDS * sampleRate),
+ );
+ const attack = Math.exp(-1 / (LIMITER_ATTACK_SECONDS * sampleRate));
+ const release = Math.exp(-1 / (LIMITER_RELEASE_SECONDS * sampleRate));
+ let gain = 1;
+ for (let i = 0; i < length; i++) {
+ const target = ahead[i]!;
+ gain = target + (target < gain ? attack : release) * (gain - target);
+ for (const channel of channels) {
+ channel[i] = Math.max(-ceiling, Math.min(ceiling, channel[i]! * gain));
+ }
+ }
+}
+
+/** Each sample's minimum over itself and the `window` samples after it. */
+function slidingMinimum(values: Float32Array, window: number): Float32Array {
+ const result = new Float32Array(values.length);
+ const deque: number[] = [];
+ let head = 0;
+ for (let i = values.length - 1; i >= 0; i--) {
+ while (deque.length > head && values[deque.at(-1)!]! >= values[i]!) {
+ deque.pop();
+ }
+ deque.push(i);
+ while (deque[head]! > i + window) head++;
+ result[i] = values[deque[head]!]!;
+ }
+ return result;
+}
+
+function blockLoudness(channelMeanSquares: readonly number[]): number {
+ const sum = channelMeanSquares.reduce((total, z) => total + z, 0);
+ return LOUDNESS_OFFSET + 10 * Math.log10(sum);
+}
+
+function gatedLoudness(blocks: readonly number[][], channelCount: number) {
+ let sum = 0;
+ for (let channel = 0; channel < channelCount; channel++) {
+ sum +=
+ blocks.reduce((total, block) => total + block[channel]!, 0) /
+ blocks.length;
+ }
+ return LOUDNESS_OFFSET + 10 * Math.log10(sum);
+}
+
+/** The two K-weighting stages as RBJ biquads (pyloudnorm's parameters). */
+function kWeighting(sampleRate: number): Biquad[] {
+ return [
+ highShelf({ sampleRate, gainDb: 4, q: 1 / Math.SQRT2, frequency: 1500 }),
+ highPass({ sampleRate, q: 0.5, frequency: 38 }),
+ ];
+}
+
+function highShelf({
+ sampleRate,
+ gainDb,
+ q,
+ frequency,
+}: {
+ sampleRate: number;
+ gainDb: number;
+ q: number;
+ frequency: number;
+}): Biquad {
+ const a = 10 ** (gainDb / 40);
+ const w0 = (2 * Math.PI * frequency) / sampleRate;
+ const alpha = Math.sin(w0) / (2 * q);
+ const cos = Math.cos(w0);
+ const root = 2 * Math.sqrt(a) * alpha;
+ const a0 = a + 1 - (a - 1) * cos + root;
+ return normalized(
+ [
+ a * (a + 1 + (a - 1) * cos + root),
+ -2 * a * (a - 1 + (a + 1) * cos),
+ a * (a + 1 + (a - 1) * cos - root),
+ ],
+ [a0, 2 * (a - 1 - (a + 1) * cos), a + 1 - (a - 1) * cos - root],
+ );
+}
+
+function highPass({
+ sampleRate,
+ q,
+ frequency,
+}: {
+ sampleRate: number;
+ q: number;
+ frequency: number;
+}): Biquad {
+ const w0 = (2 * Math.PI * frequency) / sampleRate;
+ const alpha = Math.sin(w0) / (2 * q);
+ const cos = Math.cos(w0);
+ return normalized(
+ [(1 + cos) / 2, -(1 + cos), (1 + cos) / 2],
+ [1 + alpha, -2 * cos, 1 - alpha],
+ );
+}
+
+function normalized(
+ b: [number, number, number],
+ [a0, a1, a2]: [number, number, number],
+): Biquad {
+ return { b: [b[0] / a0, b[1] / a0, b[2] / a0], a: [a1 / a0, a2 / a0] };
+}
+
+function applyBiquad(input: Float32Array, { b, a }: Biquad): Float32Array {
+ const output = new Float32Array(input.length);
+ let x1 = 0;
+ let x2 = 0;
+ let y1 = 0;
+ let y2 = 0;
+ for (let i = 0; i < input.length; i++) {
+ const x = input[i]!;
+ const y = b[0] * x + b[1] * x1 + b[2] * x2 - a[0] * y1 - a[1] * y2;
+ output[i] = y;
+ x2 = x1;
+ x1 = x;
+ y2 = y1;
+ y1 = y;
+ }
+ return output;
+}
diff --git a/app/features/scanner/core/montage.ts b/app/features/scanner/core/montage.ts
new file mode 100644
index 000000000..cd7b7f47c
--- /dev/null
+++ b/app/features/scanner/core/montage.ts
@@ -0,0 +1,220 @@
+/**
+ * The tournament montage (dev only, `view=montage`): a folder of a
+ * tournament's match VoDs downloaded by `pnpm vods:download`, described by
+ * its `tournament.json` manifest, which streaks of its games are clip
+ * candidates (a length cap per splat count, so a longer streak may run
+ * longer), and how a picked clip plays back in the rendered video — the
+ * dead time between two kills fast-forwarded, keeping `KILL_MARGIN_S` of
+ * normal speed after a kill and before the next one.
+ */
+
+import type {
+ MainWeaponId,
+ ModeShort,
+ StageId,
+} from "~/modules/in-game-lists/types";
+import { weaponIdToBaseWeaponId } from "~/modules/in-game-lists/weapon-ids";
+import { type ClipWindow, scoreWindows } from "./clips/scoring";
+import type { ScannerMatch } from "./scanner-match";
+
+export const MONTAGE_MANIFEST_FILE = "tournament.json";
+/** the last one also covers longer streaks */
+export const MONTAGE_KILL_COUNTS = [2, 3, 4, 5] as const;
+export const MONTAGE_MAX_SECONDS_OPTIONS = [20, 30, 45, 60, 90] as const;
+export const DEFAULT_MONTAGE_CRITERIA: MontageCriteria = {
+ minKills: 3,
+ maxSecondsByKills: { 2: 20, 3: 30, 4: 45, 5: 60 },
+};
+export const FAST_FORWARD_SPEED = 4;
+const KILL_MARGIN_S = 3;
+/** a shorter stretch of dead time isn't worth the jump in speed */
+const MIN_FAST_FORWARD_S = 2;
+
+/** Written next to the VoDs by `scripts/download-tournament-vods.ts`; image fields name files in the same folder. */
+export interface MontageManifest {
+ tournamentId: number;
+ name: string;
+ /** epoch ms */
+ startsAt: number;
+ logo: string | null;
+ tier: number | null;
+ organization: { name: string; logo: string | null } | null;
+ teamsCount: number;
+ playersCount: number;
+ /** best first, ties share a placement; players are the ones who played */
+ topTeams: Array;
+ vods: MontageManifestVod[];
+}
+
+export interface MontageManifestTeam {
+ id: number;
+ name: string;
+ logo: string | null;
+ players: Array<{ name: string; countryCode: string | null }>;
+}
+
+export interface MontageManifestVod {
+ file: string;
+ matchId: number;
+ /** the Twitch account the VoD is from */
+ account: string;
+ /** whose POV the VoD is; null for casts */
+ team: { id: number; name: string } | null;
+ /** e.g. "Winners Round 2" */
+ roundName: string | null;
+ bracketName: string | null;
+ /** the match's two sides */
+ teams: Array>;
+ /** the streamer, when they have a sendou.ink account */
+ pov: { name: string; profilePath: string; avatar: string | null } | null;
+}
+
+/** What makes a streak a clip candidate. */
+export interface MontageCriteria {
+ minKills: number;
+ /** the clip's length cap before fast-forwarding, by splat count (`MONTAGE_KILL_COUNTS`) */
+ maxSecondsByKills: Record;
+}
+
+/** A scanned game, as much of it as clip scoring needs. */
+export interface MontageGame {
+ kills: ScannerMatch["kills"];
+ mode: ModeShort | null;
+ stage: StageId | null;
+ /** the POV player's death times */
+ deaths: number[];
+ /** read off the POV player's scoreboard row; null when no scoreboard placed them */
+ povWeaponId: MainWeaponId | null;
+ /** the scoreboard's two sides, told apart by the POV seat; null without one */
+ lineups: {
+ pov: MontageLineupPlayer[];
+ opponent: MontageLineupPlayer[];
+ } | null;
+}
+
+export interface MontageLineupPlayer {
+ name: string | null;
+ weaponId: MainWeaponId | null;
+}
+
+export interface MontageCandidate {
+ /** the VoD and the clip's span in it */
+ key: string;
+ vod: MontageManifestVod;
+ window: ClipWindow;
+ mode: ModeShort | null;
+ stage: StageId | null;
+ /** the POV player's weapon off the game's scoreboard */
+ weaponId: MainWeaponId | null;
+ /** seconds the clip lasts in the montage, dead time fast-forwarded */
+ duration: number;
+}
+
+export interface PlaybackSegment {
+ /** seconds into the source file */
+ from: number;
+ /** seconds into the source file */
+ to: number;
+ speed: number;
+}
+
+/** The clip's playback, chronological and gapless: normal speed except fast-forwarded dead time between kills. */
+export function playbackSegments(clip: {
+ start: number;
+ end: number;
+ killTimes: readonly number[];
+}): PlaybackSegment[] {
+ const segments: PlaybackSegment[] = [];
+ let cursor = clip.start;
+ const kills = clip.killTimes.toSorted((a, b) => a - b);
+ for (let i = 1; i < kills.length; i++) {
+ const from = Math.max(cursor, kills[i - 1]! + KILL_MARGIN_S);
+ const to = Math.min(clip.end, kills[i]! - KILL_MARGIN_S);
+ if (to - from < MIN_FAST_FORWARD_S) continue;
+ if (from > cursor) segments.push({ from: cursor, to: from, speed: 1 });
+ segments.push({ from, to, speed: FAST_FORWARD_SPEED });
+ cursor = to;
+ }
+ if (clip.end > cursor)
+ segments.push({ from: cursor, to: clip.end, speed: 1 });
+ return segments;
+}
+
+/** Seconds the segments last once played back. */
+export function playbackDuration(segments: readonly PlaybackSegment[]): number {
+ return segments.reduce(
+ (sum, segment) => sum + (segment.to - segment.from) / segment.speed,
+ 0,
+ );
+}
+
+/**
+ * The game's clip candidates, chronological: streaks of at least `minKills`
+ * splats that fit the length cap of their splat count. Where a streak
+ * qualifies in several ways (5 splats in 50 s, or its first 3 in 25 s) the
+ * best-scoring reading wins and no splat is in two candidates.
+ */
+export function montageWindows(
+ game: Pick,
+ criteria: MontageCriteria,
+): ClipWindow[] {
+ const windows = MONTAGE_KILL_COUNTS.filter(
+ (kills) => kills >= criteria.minKills,
+ )
+ .flatMap((kills) =>
+ scoreWindows(game, game.deaths, {
+ minKills: kills,
+ maxSeconds: maxSecondsFor(criteria, kills),
+ }),
+ )
+ .filter(
+ (window) =>
+ window.end - window.start <= maxSecondsFor(criteria, window.kills),
+ )
+ .sort((a, b) => b.score - a.score);
+
+ const kept: ClipWindow[] = [];
+ for (const window of windows) {
+ const sharesKill = kept.some((other) =>
+ other.killTimes.some((t) => window.killTimes.includes(t)),
+ );
+ if (!sharesKill) kept.push(window);
+ }
+ return kept.sort((a, b) => a.start - b.start);
+}
+
+/** The length cap for a streak of `kills` splats. */
+export function maxSecondsFor(
+ criteria: MontageCriteria,
+ kills: number,
+): number {
+ const counted = Math.min(kills, MONTAGE_KILL_COUNTS.at(-1)!);
+ return (
+ criteria.maxSecondsByKills[counted] ??
+ DEFAULT_MONTAGE_CRITERIA.maxSecondsByKills[counted]!
+ );
+}
+
+/**
+ * The candidates grouped by the base of their weapon (Tentatek Splattershot
+ * with the Splattershot), each group keeping the candidates' order; groups
+ * come in the order of their first candidate, unknown weapons last.
+ */
+export function candidatesByBaseWeapon<
+ T extends Pick,
+>(candidates: readonly T[]) {
+ const groups = new Map();
+ for (const candidate of candidates) {
+ const baseWeaponId =
+ candidate.weaponId === null
+ ? null
+ : weaponIdToBaseWeaponId(candidate.weaponId);
+ groups.set(baseWeaponId, [...(groups.get(baseWeaponId) ?? []), candidate]);
+ }
+ return [...groups]
+ .map(([baseWeaponId, grouped]) => ({ baseWeaponId, candidates: grouped }))
+ .sort(
+ (a, b) =>
+ Number(a.baseWeaponId === null) - Number(b.baseWeaponId === null),
+ );
+}
diff --git a/app/features/scanner/scanner-search-params.test.ts b/app/features/scanner/scanner-search-params.test.ts
index c2bbc42a5..88a95a842 100644
--- a/app/features/scanner/scanner-search-params.test.ts
+++ b/app/features/scanner/scanner-search-params.test.ts
@@ -8,7 +8,16 @@ import { scannerSearchParams } from "./scanner-search-params";
describe("scannerSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(scannerSearchParams, {
- view: ["home", "live", "session", "vod", "clips", "debug", "fixtures"],
+ view: [
+ "home",
+ "live",
+ "session",
+ "vod",
+ "clips",
+ "debug",
+ "fixtures",
+ "montage",
+ ],
id: [1758040920000, 0, null],
name: ["sws26-finals.mkv", "a b.mp4", null],
q: ["", "gauge-overlay", "player-status/cast,ready-trough"],
diff --git a/app/features/scanner/scanner-search-params.ts b/app/features/scanner/scanner-search-params.ts
index 703736047..f2c345a75 100644
--- a/app/features/scanner/scanner-search-params.ts
+++ b/app/features/scanner/scanner-search-params.ts
@@ -11,6 +11,7 @@ const SCANNER_VIEWS = [
"clips",
"debug",
"fixtures",
+ "montage",
] as const;
export const scannerSearchParams = SearchParams.define({
diff --git a/app/features/scanner/store/db.ts b/app/features/scanner/store/db.ts
index b15c6748b..03a912369 100644
--- a/app/features/scanner/store/db.ts
+++ b/app/features/scanner/store/db.ts
@@ -9,9 +9,10 @@
* - `clips`: clip records by auto id, indexed by bucket (clips.ts)
* - `clip-blobs`: the clips' MP4s, keyed by clip id
* - `inspect-frames`: one-shot Inspect handoffs into a new debug tab (inspect.ts)
+ * - `montages`: the dev-only tournament montage's state, keyed by tournament id (montages.ts)
*/
const DB_NAME = "scanner";
-const DB_VERSION = 3;
+const DB_VERSION = 4;
export const EVENTS_STORE = "events";
export const FRAMES_STORE = "frames";
@@ -22,12 +23,13 @@ export const VOD_FRAMES_STORE = "vod-frames";
export const CLIPS_STORE = "clips";
export const CLIP_BLOBS_STORE = "clip-blobs";
export const INSPECT_FRAMES_STORE = "inspect-frames";
+export const MONTAGES_STORE = "montages";
/**
* Adds the stores a DB_VERSION bump introduced, keeping the existing ones and
* their data. v2 added the clip stores and moved live event times onto the
* wall clock, so a v1 database's live events (stamped on the page clock)
- * are dropped; v3 added the compacted matches. Changing an existing store's
+ * are dropped; v3 added the compacted matches, v4 the montages. Changing an existing store's
* shape needs a real migration here.
*/
function upgrade(database: IDBDatabase, oldVersion: number): void {
@@ -74,6 +76,10 @@ function upgrade(database: IDBDatabase, oldVersion: number): void {
clips.createIndex("bucket", "bucket");
}
+ if (!has(MONTAGES_STORE)) {
+ database.createObjectStore(MONTAGES_STORE, { keyPath: "tournamentId" });
+ }
+
for (const name of [
FRAMES_STORE,
VOD_FRAMES_STORE,
diff --git a/app/features/scanner/store/montages.ts b/app/features/scanner/store/montages.ts
new file mode 100644
index 000000000..a585fd28f
--- /dev/null
+++ b/app/features/scanner/store/montages.ts
@@ -0,0 +1,54 @@
+/**
+ * The dev-only tournament montage's state, one record per tournament, so a
+ * reload keeps what took long to get: every scanned VoD's games (reading
+ * them back from `vod-events` means building every VoD's matches again),
+ * the picks, the music file and the folder's handle to reconnect it with.
+ */
+import type {
+ MontageCandidate,
+ MontageGame,
+ MontageManifest,
+ MontageManifestVod,
+} from "../core/montage";
+import { MONTAGES_STORE, tx } from "./db";
+
+export interface StoredMontage {
+ tournamentId: number;
+ manifest: MontageManifest;
+ /** the manifest's VoDs the folder had when last opened */
+ vods: MontageManifestVod[];
+ /** scanned games by VoD file name */
+ games: Record;
+ /** in montage order */
+ picks: MontageCandidate[];
+ /** plays under the whole video instead of the game sound; absent in records from before it existed */
+ music?: File | null;
+ /** null when the folder was opened without the File System Access API */
+ directory: FileSystemDirectoryHandle | null;
+ /** epoch ms; the latest opened montage is restored on load */
+ openedAt: number;
+}
+
+export async function putMontage(montage: StoredMontage): Promise {
+ await tx(MONTAGES_STORE, "readwrite", (store) => store.put(montage));
+}
+
+export function loadMontage(
+ tournamentId: number,
+): Promise {
+ return tx(
+ MONTAGES_STORE,
+ "readonly",
+ (store) => store.get(tournamentId) as IDBRequest,
+ );
+}
+
+/** The montage opened most recently. */
+export async function latestMontage(): Promise {
+ const montages = await tx(
+ MONTAGES_STORE,
+ "readonly",
+ (store) => store.getAll() as IDBRequest,
+ );
+ return montages.toSorted((a, b) => b.openedAt - a.openedAt)[0];
+}
diff --git a/app/features/scanner/tests/logic/loudness.test.ts b/app/features/scanner/tests/logic/loudness.test.ts
new file mode 100644
index 000000000..3dbeb0765
--- /dev/null
+++ b/app/features/scanner/tests/logic/loudness.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, test } from "vitest";
+import { integratedLoudness, limitPeaks } from "../../core/loudness";
+
+const SAMPLE_RATE = 48_000;
+
+function sine({
+ amplitude,
+ seconds,
+ frequency = 997,
+}: {
+ amplitude: number;
+ seconds: number;
+ frequency?: number;
+}) {
+ return Float32Array.from(
+ { length: Math.round(seconds * SAMPLE_RATE) },
+ (_, i) => amplitude * Math.sin((2 * Math.PI * frequency * i) / SAMPLE_RATE),
+ );
+}
+
+describe("integratedLoudness", () => {
+ // BS.1770: a 0 dBFS 1 kHz sine in one of left/right reads -3.01 LUFS
+ test.each([
+ { why: "-20 dBFS sine in both channels", channels: 2, expected: -20 },
+ { why: "-20 dBFS sine in one channel", channels: 1, expected: -23.01 },
+ ])("$why", ({ channels, expected }) => {
+ const tone = sine({ amplitude: 0.1, seconds: 3 });
+
+ expect(
+ integratedLoudness(Array(channels).fill(tone), SAMPLE_RATE),
+ ).toBeCloseTo(expected, 1);
+ });
+
+ // only the few blocks straddling the edge count at partial level
+ test("silence gates out, so it barely lowers the loudness", () => {
+ const tone = sine({ amplitude: 0.1, seconds: 3 });
+ const withSilence = new Float32Array(tone.length * 2);
+ withSilence.set(tone);
+
+ expect(integratedLoudness([withSilence], SAMPLE_RATE)).toBeCloseTo(
+ integratedLoudness([tone], SAMPLE_RATE),
+ 0,
+ );
+ });
+
+ test("pure silence has no loudness", () => {
+ expect(
+ integratedLoudness([new Float32Array(SAMPLE_RATE)], SAMPLE_RATE),
+ ).toBe(Number.NEGATIVE_INFINITY);
+ });
+
+ test("coefficients hold at 44.1 kHz too", () => {
+ const rate = 44_100;
+ const tone = Float32Array.from(
+ { length: 3 * rate },
+ (_, i) => 0.1 * Math.sin((2 * Math.PI * 997 * i) / rate),
+ );
+
+ expect(integratedLoudness([tone, tone], rate)).toBeCloseTo(-20, 1);
+ });
+});
+
+describe("limitPeaks", () => {
+ const CEILING = 0.9;
+
+ test("leaves audio under the ceiling as it is", () => {
+ const tone = sine({ amplitude: 0.5, seconds: 1 });
+ const limited = tone.slice();
+
+ limitPeaks([limited], SAMPLE_RATE, CEILING);
+
+ expect(limited).toEqual(tone);
+ });
+
+ test("keeps a spike under the ceiling and recovers after it", () => {
+ const tone = sine({ amplitude: 0.5, seconds: 2 });
+ const spiked = tone.slice();
+ spiked[SAMPLE_RATE / 2] = 2;
+ const other = tone.slice();
+
+ limitPeaks([spiked, other], SAMPLE_RATE, CEILING);
+
+ const peak = [spiked, other].reduce(
+ (max, channel) =>
+ channel.reduce(
+ (channelMax, x) => Math.max(channelMax, Math.abs(x)),
+ max,
+ ),
+ 0,
+ );
+ expect(peak).toBeLessThanOrEqual(CEILING);
+ // both channels dip together
+ expect(other[SAMPLE_RATE / 2]!).toBeCloseTo(
+ (tone[SAMPLE_RATE / 2]! * CEILING) / 2,
+ 2,
+ );
+ const later = SAMPLE_RATE + 100;
+ expect(spiked[later]!).toBeCloseTo(tone[later]!, 2);
+ });
+});
diff --git a/app/features/scanner/tests/logic/montage-comps.test.ts b/app/features/scanner/tests/logic/montage-comps.test.ts
new file mode 100644
index 000000000..dc6bdcbf6
--- /dev/null
+++ b/app/features/scanner/tests/logic/montage-comps.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, test } from "vitest";
+import { scannedTeamComps } from "../../components/montage-comps";
+import type { MontageGame, MontageManifestVod } from "../../core/montage";
+
+const vod: MontageManifestVod = {
+ file: "1-streamer.mp4",
+ matchId: 1,
+ account: "streamer",
+ team: { id: 10, name: "Own" },
+ roundName: null,
+ bracketName: null,
+ teams: [
+ { id: 10, name: "Own", logo: null },
+ { id: 20, name: "Other", logo: null },
+ ],
+ pov: null,
+};
+
+const lineup = (weaponIds: number[]) =>
+ weaponIds.map((weaponId, i) => ({ name: `p${i}`, weaponId }));
+
+function game(own: number[], opponent: number[] | null): MontageGame {
+ return {
+ kills: null,
+ mode: null,
+ stage: null,
+ deaths: [],
+ povWeaponId: own[0] ?? null,
+ lineups: opponent ? { pov: lineup(own), opponent: lineup(opponent) } : null,
+ } as MontageGame;
+}
+
+describe("scannedTeamComps", () => {
+ test("the POV seat's side is the streamer's team, the other side their opponent", () => {
+ const comps = scannedTeamComps(
+ [vod],
+ new Map([[vod.file, [game([40, 50, 2000, 3040], [10, 20, 30, 1000])]]]),
+ );
+
+ expect(comps.get(10)?.toSorted()).toEqual([2000, 3040, 40, 50].toSorted());
+ expect(comps.get(20)?.toSorted()).toEqual([10, 1000, 20, 30].toSorted());
+ });
+
+ test("a team with fewer than four players seen has no comp", () => {
+ const comps = scannedTeamComps(
+ [vod],
+ new Map([
+ [vod.file, [game([40, 50], [10, 20, 30, 1000]), game([], null)]],
+ ]),
+ );
+
+ expect(comps.has(10)).toBe(false);
+ expect(comps.has(20)).toBe(true);
+ });
+});
diff --git a/app/features/scanner/tests/logic/montage.test.ts b/app/features/scanner/tests/logic/montage.test.ts
new file mode 100644
index 000000000..01f375cea
--- /dev/null
+++ b/app/features/scanner/tests/logic/montage.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, test } from "vitest";
+import {
+ candidatesByBaseWeapon,
+ FAST_FORWARD_SPEED,
+ montageWindows,
+ playbackDuration,
+ playbackSegments,
+} from "../../core/montage";
+
+const FF = FAST_FORWARD_SPEED;
+
+describe("playbackSegments", () => {
+ test.each([
+ {
+ why: "kills close together play at normal speed",
+ clip: { start: 95, end: 121, killTimes: [100, 105, 110, 117] },
+ expected: [{ from: 95, to: 121, speed: 1 }],
+ },
+ {
+ why: "a long gap fast-forwards 3 s after the kill until 3 s before the next",
+ clip: { start: 95, end: 134, killTimes: [100, 102, 104, 130] },
+ expected: [
+ { from: 95, to: 107, speed: 1 },
+ { from: 107, to: 127, speed: FF },
+ { from: 127, to: 134, speed: 1 },
+ ],
+ },
+ {
+ why: "dead time shorter than the minimum stays at normal speed",
+ clip: { start: 95, end: 118, killTimes: [100, 101, 102, 109] },
+ expected: [{ from: 95, to: 118, speed: 1 }],
+ },
+ {
+ why: "every long gap fast-forwards on its own",
+ clip: { start: 95, end: 139, killTimes: [100, 112, 124, 135] },
+ expected: [
+ { from: 95, to: 103, speed: 1 },
+ { from: 103, to: 109, speed: FF },
+ { from: 109, to: 115, speed: 1 },
+ { from: 115, to: 121, speed: FF },
+ { from: 121, to: 127, speed: 1 },
+ { from: 127, to: 132, speed: FF },
+ { from: 132, to: 139, speed: 1 },
+ ],
+ },
+ ])("$why", ({ clip, expected }) => {
+ expect(playbackSegments(clip)).toEqual(expected);
+ });
+});
+
+describe("playbackDuration", () => {
+ test("fast-forwarded seconds count by their speed", () => {
+ expect(
+ playbackDuration([
+ { from: 0, to: 10, speed: 1 },
+ { from: 10, to: 18, speed: 4 },
+ ]),
+ ).toBe(12);
+ });
+});
+
+describe("montageWindows", () => {
+ const game = (...killTimes: number[]) => ({
+ kills: killTimes.map((t) => ({ t, time: null, name: null })),
+ deaths: [],
+ });
+ const criteria = {
+ minKills: 3,
+ maxSecondsByKills: { 2: 20, 3: 30, 4: 45, 5: 60 },
+ };
+ const spans = (windows: ReturnType) =>
+ windows.map((window) => [window.start, window.end, window.kills]);
+
+ test.each([
+ {
+ why: "a 3-splat streak within its cap",
+ kills: [100, 110, 120],
+ expected: [[95, 124, 3]],
+ },
+ {
+ why: "a 3-splat streak over its cap",
+ kills: [100, 112, 124],
+ expected: [],
+ },
+ {
+ why: "a 4-splat streak may run longer than a 3-splat one",
+ kills: [100, 112, 124, 136],
+ expected: [[95, 140, 4]],
+ },
+ {
+ why: "a streak longer than the last count uses its cap",
+ kills: [100, 108, 116, 124, 132, 140],
+ expected: [[95, 144, 6]],
+ },
+ {
+ why: "streaks under the minimum are left out",
+ kills: [100, 105],
+ expected: [],
+ },
+ ])("$why", ({ kills, expected }) => {
+ expect(spans(montageWindows(game(...kills), criteria))).toEqual(expected);
+ });
+
+ test("the best reading of a streak wins and splats aren't shared", () => {
+ // all five fit 60 s; their first three alone would also fit 30 s
+ const windows = montageWindows(game(100, 105, 110, 125, 140), criteria);
+
+ expect(spans(windows)).toEqual([[95, 144, 5]]);
+ });
+});
+
+describe("candidatesByBaseWeapon", () => {
+ test("groups variants under their base weapon in order of their first candidate, unknown last", () => {
+ const candidates = [
+ { id: "a", weaponId: null },
+ { id: "b", weaponId: 41 },
+ { id: "c", weaponId: 210 },
+ { id: "d", weaponId: 40 },
+ ] as const;
+
+ expect(
+ candidatesByBaseWeapon(candidates).map((group) => [
+ group.baseWeaponId,
+ group.candidates.map((candidate) => candidate.id),
+ ]),
+ ).toEqual([
+ [40, ["b", "d"]],
+ [210, ["c"]],
+ [null, ["a"]],
+ ]);
+ });
+});
diff --git a/app/features/tournament-bracket/TournamentMatchVodRepository.server.ts b/app/features/tournament-bracket/TournamentMatchVodRepository.server.ts
index 56f5952e6..3b7f2167d 100644
--- a/app/features/tournament-bracket/TournamentMatchVodRepository.server.ts
+++ b/app/features/tournament-bracket/TournamentMatchVodRepository.server.ts
@@ -65,7 +65,8 @@ export function findVodsByTournamentId(tournamentId: number) {
/**
* Every VoD of the tournament with what is needed to download its match: its round, when the match started and
- * when its last game was reported, plus the team whose POV the VoD is and how many sets that team won (`null` for casts).
+ * when its last game was reported, plus the streamer and the team whose POV the VoD is and how many sets that team
+ * won (`null` for casts).
*/
export async function findAllForDownloadByTournamentId(tournamentId: number) {
const [vods, finishedMatches] = await Promise.all([
@@ -94,6 +95,7 @@ export async function findAllForDownloadByTournamentId(tournamentId: number) {
.select((eb) => [
"TournamentMatchVod.matchId",
"TournamentMatchVod.account",
+ "TournamentMatchVod.userId",
"TournamentMatchVod.platformVideoId",
"TournamentMatchVod.timestampSeconds",
"TournamentMatch.startedAt",
diff --git a/scripts/download-tournament-vods.ts b/scripts/download-tournament-vods.ts
index 4db20d9b1..3b392da80 100644
--- a/scripts/download-tournament-vods.ts
+++ b/scripts/download-tournament-vods.ts
@@ -6,18 +6,28 @@
*
* VoDs of teams that won no sets (e.g. went 0-2) are skipped unless --include-winless is passed.
* Files go to scripts/output/vods//-.mp4 and existing ones are not redownloaded.
+ * Next to them go tournament.json and the logos it names, for the scanner's tournament montage (`/scanner?view=montage`, dev only).
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as R from "remeda";
+import {
+ MONTAGE_MANIFEST_FILE,
+ type MontageManifest,
+} from "~/features/scanner/core/montage";
+import * as Standings from "~/features/tournament/core/Standings";
+import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
+import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentMatchVodRepository from "~/features/tournament-bracket/TournamentMatchVodRepository.server";
import { vodTimestampOffsetSeconds } from "~/routines/syncTournamentVods";
import { invariant } from "~/utils/invariant";
import { logger } from "~/utils/logger";
+import { resolveAvatarUrl, userPage } from "~/utils/urls";
const SECONDS_AFTER_LAST_REPORT = 30;
+const MONTAGE_TOP_TEAMS_COUNT = 8;
const OUTPUT_ROOT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
@@ -39,6 +49,8 @@ const vods =
const outputDir = path.join(OUTPUT_ROOT, String(tournamentId));
fs.mkdirSync(outputDir, { recursive: true });
+if (!dryRun) await writeMontageManifest();
+
const sectionsToDownload: Array<{
name: string;
platformVideoId: string;
@@ -120,6 +132,132 @@ if (failed.length > 0) {
process.exitCode = 1;
}
+async function writeMontageManifest() {
+ const [tournament, fullTeams] = await Promise.all([
+ tournamentFromDB(tournamentId),
+ TournamentRepository.findTeamsFullByTournamentId(tournamentId),
+ ]);
+ const fullTeamById = new Map(fullTeams.map((team) => [team.id, team]));
+ const standings = Standings.tournamentStandings(tournament);
+ const topStandings = (
+ standings.type === "single"
+ ? standings.standings
+ : (standings.standings[0]?.standings ?? [])
+ ).slice(0, MONTAGE_TOP_TEAMS_COUNT);
+
+ const teamOf = async (teamId: number) => {
+ const team = tournament.teamById(teamId);
+ invariant(team, `Team ${teamId} not found`);
+
+ return {
+ id: team.id,
+ name: team.name,
+ logo: await downloadImage(team.logoUrl, `team-${team.id}`),
+ };
+ };
+
+ const manifest: MontageManifest = {
+ tournamentId,
+ name: tournament.ctx.name,
+ startsAt: tournament.ctx.startsAt.getTime(),
+ logo: await downloadImage(tournament.ctx.logoUrl, "logo"),
+ tier: tournament.ctx.tier ?? null,
+ organization: tournament.ctx.organization
+ ? {
+ name: tournament.ctx.organization.name,
+ logo: await downloadImage(
+ tournament.ctx.organization.logoUrl,
+ "organization",
+ ),
+ }
+ : null,
+ teamsCount: tournament.ctx.teams.length,
+ playersCount: tournament.participatedUserIds?.length ?? 0,
+ topTeams: await Promise.all(
+ topStandings.map(async (standing) => {
+ const playedUserIds = new Set(
+ tournament.participatedPlayerUserIdsByTeamId(standing.team.id),
+ );
+
+ return {
+ ...(await teamOf(standing.team.id)),
+ placement: standing.placement,
+ players: (fullTeamById.get(standing.team.id)?.members ?? [])
+ .filter((member) => playedUserIds.has(member.userId))
+ .map((member) => ({
+ name: member.username,
+ countryCode: member.country,
+ })),
+ };
+ }),
+ ),
+ vods: await Promise.all(
+ vods.map(async (vod) => {
+ const team =
+ vod.povTeamId === null ? null : tournament.teamById(vod.povTeamId);
+ const streamer =
+ vod.povTeamId === null
+ ? undefined
+ : fullTeamById
+ .get(vod.povTeamId)
+ ?.members.find((member) => member.userId === vod.userId);
+ const match = tournament.brackets
+ .flatMap((bracket) => (bracket.preview ? [] : bracket.data.match))
+ .find((candidate) => candidate.id === vod.matchId);
+ const { roundNameWithoutMatchIdentifier, bracketName } =
+ tournament.matchContextNamesById(vod.matchId);
+
+ return {
+ file: `${vod.matchId}-${vod.account}.mp4`,
+ matchId: vod.matchId,
+ account: vod.account,
+ team: team ? { id: team.id, name: team.name } : null,
+ roundName: roundNameWithoutMatchIdentifier ?? null,
+ bracketName: bracketName ?? null,
+ teams: await Promise.all(
+ [match?.opponent1?.id, match?.opponent2?.id]
+ .filter((teamId) => typeof teamId === "number")
+ .map(teamOf),
+ ),
+ pov: streamer
+ ? {
+ name: streamer.username,
+ profilePath: userPage(streamer),
+ avatar: await downloadImage(
+ resolveAvatarUrl({ ...streamer, size: "sm" }) ?? null,
+ `user-${streamer.userId}`,
+ ),
+ }
+ : null,
+ };
+ }),
+ ),
+ };
+
+ fs.writeFileSync(
+ path.join(outputDir, MONTAGE_MANIFEST_FILE),
+ JSON.stringify(manifest, null, 2),
+ );
+}
+
+/** Saves the image next to the VoDs, returning its file name; null when there is none or it can't be fetched. */
+async function downloadImage(url: string | null, baseName: string) {
+ if (!url) return null;
+
+ const fileName = `${baseName}${path.extname(new URL(url).pathname)}`;
+ const filePath = path.join(outputDir, fileName);
+ if (fs.existsSync(filePath)) return fileName;
+
+ const response = await fetch(url);
+ if (!response.ok) {
+ logger.error(`Failed to download ${url}: ${response.status}`);
+ return null;
+ }
+ fs.writeFileSync(filePath, Buffer.from(await response.arrayBuffer()));
+
+ return fileName;
+}
+
function finalFilePath(name: string) {
return path.join(outputDir, `${name}.mp4`);
}