From 0b8dc44f8465aa1fd9d53469c380534b0ff7db03 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:03:43 +0300 Subject: [PATCH] Montage PoC --- app/features/scanner/README.md | 51 +- .../scanner/capture/montage-overlays.ts | 92 +++ .../scanner/capture/montage-render.ts | 401 ++++++++++ .../components/MontageGraphics.module.css | 114 +++ .../scanner/components/MontageGraphics.tsx | 306 ++++++++ .../scanner/components/MontageView.module.css | 250 +++++++ .../scanner/components/MontageView.tsx | 638 ++++++++++++++++ .../scanner/components/ScannerApp.tsx | 12 +- .../scanner/components/SettingsPopover.tsx | 34 +- .../scanner/components/montage-comps.ts | 63 ++ app/features/scanner/components/montage.ts | 693 ++++++++++++++++++ app/features/scanner/components/vod-scan.ts | 11 +- app/features/scanner/core/clips/scoring.ts | 3 + app/features/scanner/core/loudness.ts | 210 ++++++ app/features/scanner/core/montage.ts | 220 ++++++ .../scanner/scanner-search-params.test.ts | 11 +- app/features/scanner/scanner-search-params.ts | 1 + app/features/scanner/store/db.ts | 10 +- app/features/scanner/store/montages.ts | 54 ++ .../scanner/tests/logic/loudness.test.ts | 100 +++ .../scanner/tests/logic/montage-comps.test.ts | 55 ++ .../scanner/tests/logic/montage.test.ts | 132 ++++ .../TournamentMatchVodRepository.server.ts | 4 +- scripts/download-tournament-vods.ts | 138 ++++ 24 files changed, 3581 insertions(+), 22 deletions(-) create mode 100644 app/features/scanner/capture/montage-overlays.ts create mode 100644 app/features/scanner/capture/montage-render.ts create mode 100644 app/features/scanner/components/MontageGraphics.module.css create mode 100644 app/features/scanner/components/MontageGraphics.tsx create mode 100644 app/features/scanner/components/MontageView.module.css create mode 100644 app/features/scanner/components/MontageView.tsx create mode 100644 app/features/scanner/components/montage-comps.ts create mode 100644 app/features/scanner/components/montage.ts create mode 100644 app/features/scanner/core/loudness.ts create mode 100644 app/features/scanner/core/montage.ts create mode 100644 app/features/scanner/store/montages.ts create mode 100644 app/features/scanner/tests/logic/loudness.test.ts create mode 100644 app/features/scanner/tests/logic/montage-comps.test.ts create mode 100644 app/features/scanner/tests/logic/montage.test.ts 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) => ( +
  1. + {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)} + /> + +
  2. + ))} +
+ )} +
+ ); +} + +/** 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 +
+ ); +} + +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`); }