Montage PoC

This commit is contained in:
Kalle
2026-09-28 07:03:43 +03:00
parent 8a76fbad5a
commit 0b8dc44f84
24 changed files with 3581 additions and 22 deletions

View File

@@ -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 <id>`
writes the tournament's match VoDs to `scripts/output/vods/<id>/` 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

View File

@@ -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;
}

View File

@@ -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<StreamTargetChunk> | null;
signal: AbortSignal;
onProgress: (progress: MontageRenderProgress) => void;
}): Promise<Blob | null> {
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<void> {
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<void> {
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<typeof outputTimeline>,
frameCount: number,
): Promise<AudioBuffer> {
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<AudioBuffer | null> {
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<AudioBuffer> {
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);
}

View File

@@ -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);
}

View File

@@ -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<number, MainWeaponId[]>;
}) {
const [request, setRequest] = React.useState<CaptureRequest | null>(null);
const graphicRef = React.useRef<HTMLDivElement>(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(
<div className={styles.stage} data-theme="dark" aria-hidden>
<div ref={graphicRef} className={styles.graphic}>
{request.graphic.kind === "title" ? (
<TitleGraphic manifest={manifest} />
) : request.graphic.kind === "standings" ? (
<Frame scale={FRAME_SCALE.standings}>
<ResultsCard
manifest={manifest}
comps={comps}
rosterCount={TOP_TEAMS_WITH_PLAYERS_IN_VIDEO}
width={880}
/>
</Frame>
) : request.graphic.kind === "results" ? (
<ResultsCard
manifest={manifest}
comps={comps}
rosterCount={manifest.topTeams.length}
/>
) : (
<RibbonGraphic
vod={request.graphic.vod}
weaponId={request.graphic.weaponId}
/>
)}
</div>
</div>,
document.body,
);
}
function TitleGraphic({ manifest }: { manifest: MontageManifest }) {
return (
<Frame scale={FRAME_SCALE.title}>
<GraphicContainer width={640}>
<Header manifest={manifest} />
<GraphicStatsRow>
<GraphicStat label="Teams">{manifest.teamsCount}</GraphicStat>
<GraphicStat label="Players">{manifest.playersCount}</GraphicStat>
</GraphicStatsRow>
<GraphicFooter>
<div>Highlights</div>
<GraphicSiteUrl path={tournamentPage(manifest.tournamentId)} />
</GraphicFooter>
</GraphicContainer>
</Frame>
);
}
/** 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<number, MainWeaponId[]>;
rosterCount: number;
width?: number;
}) {
return (
<GraphicContainer width={width}>
<Header manifest={manifest} />
<GraphicTeamsList>
{manifest.topTeams.map((team, i) => (
<GraphicTeamRow
key={team.id}
team={{
name: team.name,
logoUrl: montageImageUrl(team.logo),
players:
i < rosterCount
? team.players.map((player) => ({
name: player.name,
countryCode: player.countryCode ?? undefined,
}))
: [],
weapons: comps.get(team.id) ?? [],
}}
highlighted={team.placement === 1}
leading={<GraphicPlacementCell placement={team.placement} />}
/>
))}
</GraphicTeamsList>
<GraphicFooter>
<div>
{manifest.teamsCount} teams · {manifest.playersCount} players
</div>
<GraphicSiteUrl path={tournamentPage(manifest.tournamentId)} />
</GraphicFooter>
</GraphicContainer>
);
}
function RibbonGraphic({
vod,
weaponId,
}: {
vod: MontageManifestVod;
weaponId: MainWeaponId | null;
}) {
return (
<GraphicContainer width={1400}>
<div className={styles.ribbon}>
<div className={styles.ribbonRound}>
<GraphicBoxLabel>{vod.bracketName ?? "Match"}</GraphicBoxLabel>
<div className={styles.ribbonRoundName}>
{vod.roundName ?? `Match ${vod.matchId}`}
</div>
</div>
<div className={styles.ribbonTeams}>
{vod.teams.map((team, i) => (
<React.Fragment key={team.id}>
{i > 0 ? <span className={styles.ribbonVersus}>vs</span> : null}
<span
className={
team.id === vod.team?.id
? styles.ribbonTeamPov
: styles.ribbonTeam
}
>
<Avatar
url={montageImageUrl(team.logo)}
identiconInput={team.name}
size="xs"
loading="eager"
/>
<span className={styles.ribbonTeamName}>{team.name}</span>
</span>
</React.Fragment>
))}
</div>
<div className={styles.ribbonPov}>
{vod.pov ? (
<Avatar
url={montageImageUrl(vod.pov.avatar)}
identiconInput={vod.pov.name}
size="sm"
loading="eager"
/>
) : null}
<div className={styles.ribbonPovText}>
<GraphicBoxLabel>{vod.pov ? "POV" : "Cast"}</GraphicBoxLabel>
<div className={styles.ribbonPovName}>
{vod.pov?.name ?? `twitch.tv/${vod.account}`}
</div>
{vod.pov ? <GraphicSiteUrl path={vod.pov.profilePath} /> : null}
</div>
{weaponId === null ? null : (
<div className={styles.ribbonWeapon}>
<WeaponImage weaponSplId={weaponId} variant="badge" size={48} />
</div>
)}
</div>
</div>
</GraphicContainer>
);
}
function Header({ manifest }: { manifest: MontageManifest }) {
return (
<TournamentGraphicHeader
tournamentName={manifest.name}
startTime={new Date(manifest.startsAt)}
logoUrl={montageImageUrl(manifest.logo)}
tier={manifest.tier ?? undefined}
organization={
manifest.organization
? {
name: manifest.organization.name,
avatarUrl: montageImageUrl(manifest.organization.logo),
}
: undefined
}
/>
);
}
/** A 1920×1080 frame at `scale`, the graphic centered on the export's backdrop. */
function Frame({
scale,
children,
}: {
scale: number;
children: React.ReactNode;
}) {
return (
<div
className={styles.frame}
style={{ width: 1920 / scale, height: 1080 / scale }}
>
{children}
</div>
);
}
async function captureImage(
element: HTMLElement,
scale: number,
): Promise<Blob> {
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,
});
}

View File

@@ -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;
}
}

View File

@@ -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 (
<div className={styles.view}>
<SessionHeader
actions={
<>
{disconnected && montage.folder?.directory ? (
<SendouButton
size="small"
icon={<RefreshCw />}
onClick={() => void reconnectMontageFolder()}
>
Reconnect folder
</SendouButton>
) : null}
{montage.folder?.files ? <ResultsImageButton /> : null}
<SendouButton
size="small"
variant={disconnected ? "outlined" : "primary"}
icon={<FolderOpen />}
onClick={() => void openMontageFolder()}
>
Open folder
</SendouButton>
</>
}
>
<div className={styles.title}>
Tournament montage
{montage.folder ? ` · ${montage.folder.manifest.name}` : null}
</div>
{montage.folder ? null : (
<p className={styles.note}>
Download the VoDs with <code>pnpm vods:download &lt;id&gt;</code>,
then open <code>scripts/output/vods/&lt;id&gt;</code>.
</p>
)}
{disconnected ? (
<p className={styles.note}>
Restored from the last visit: reconnect the folder to scan, watch
and render clips.
</p>
) : null}
{montage.folderError ? (
<p className={styles.error}>{montage.folderError}</p>
) : null}
</SessionHeader>
{montage.folder ? (
<>
<ScanPanel montage={montage} />
<PicksPanel montage={montage} />
<CandidatesPanel montage={montage} />
<MontageGraphicStage
manifest={montage.folder.manifest}
comps={montageTeamComps(montage)}
/>
</>
) : null}
</div>
);
}
function ResultsImageButton() {
const [state, setState] = useState<"idle" | "capturing" | "failed">("idle");
const download = async () => {
setState("capturing");
try {
await downloadMontageResults();
setState("idle");
} catch {
setState("failed");
}
};
return (
<SendouButton
size="small"
variant="outlined"
icon={<ImageDown />}
isDisabled={state === "capturing"}
onClick={() => void download()}
>
{state === "failed" ? "Results image failed, retry" : "Results image"}
</SendouButton>
);
}
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 (
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
Scanning VoD {scan.index + 1}/{scan.total} · {scan.file}
</span>
<SendouButton
size="small"
variant="minimal-destructive"
icon={<Square />}
onClick={cancelMontageScan}
>
Stop
</SendouButton>
</div>
<ScanWorkers events={vodScan.events}>
{vodScan.error ? (
<p className={styles.error}>{vodScan.error}</p>
) : null}
</ScanWorkers>
</section>
);
}
return (
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
{scannedCount}/{vods.length} VoDs scanned
</span>
{unscanned > 0 ? (
<SendouButton
size="small"
icon={<ScanSearch />}
isDisabled={!montage.folder?.files}
onClick={() => void scanMontageVods()}
>
Scan {unscanned} {unscanned === 1 ? "VoD" : "VoDs"}
</SendouButton>
) : null}
</div>
{scan?.state === "done" && scan.failed.length > 0 ? (
<p className={styles.error}>Failed to scan: {scan.failed.join(", ")}</p>
) : null}
</section>
);
}
function PicksPanel({ montage }: { montage: MontageSnapshot }) {
const picks = montage.picks;
const render = montage.render;
const rendering = render?.state === "rendering";
return (
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
Montage · {picks.length} {picks.length === 1 ? "clip" : "clips"} ·{" "}
{formatPosition(montageDuration(picks))}
</span>
{rendering ? (
<SendouButton
size="small"
variant="minimal-destructive"
icon={<Square />}
onClick={cancelMontageRender}
>
Cancel
</SendouButton>
) : (
<SendouButton
size="small"
icon={<Film />}
isDisabled={picks.length === 0 || !montage.folder?.files}
onClick={() => void renderMontageVideo()}
>
Render video
</SendouButton>
)}
</div>
{rendering ? (
<div className={styles.renderProgress}>
<progress value={render.done} max={Math.max(1, render.total)} />
<span>
{formatPosition(render.done)} / {formatPosition(render.total)}
</span>
</div>
) : render?.state === "done" ? (
render.error ? (
<p className={styles.error}>{render.error}</p>
) : (
<p className={styles.note}>Rendered.</p>
)
) : null}
<MusicPicker music={montage.music} isDisabled={rendering} />
{picks.length === 0 ? (
<p className={styles.note}>
Pick clips below; they play in this order, between the title card and
the top {montage.folder?.manifest.topTeams.length}.
</p>
) : (
<ol className={styles.picks}>
{picks.map((candidate, i) => (
<li key={candidate.key} className={styles.pick}>
<span className={styles.pickIndex}>{i + 1}</span>
<CandidateSummary candidate={candidate} />
<span className={styles.pickActions}>
<SendouButton
size="small"
variant="minimal"
shape="circle"
icon={<ArrowUp />}
aria-label="Move earlier"
isDisabled={rendering || i === 0}
onClick={() => movePick(candidate.key, -1)}
/>
<SendouButton
size="small"
variant="minimal"
shape="circle"
icon={<ArrowDown />}
aria-label="Move later"
isDisabled={rendering || i === picks.length - 1}
onClick={() => movePick(candidate.key, 1)}
/>
<SendouButton
size="small"
variant="minimal-destructive"
shape="circle"
icon={<X />}
aria-label="Remove from montage"
isDisabled={rendering}
onClick={() => unpickCandidate(candidate.key)}
/>
</span>
</li>
))}
</ol>
)}
</section>
);
}
/** Optional: a music or video file whose sound replaces the game sound. */
function MusicPicker({
music,
isDisabled,
}: {
music: File | null;
isDisabled: boolean;
}) {
return (
<div className={styles.music}>
<span className={styles.criterionLabel}>Music</span>
{music ? (
<>
<Music size={14} aria-hidden />
<span className={styles.musicName}>{music.name}</span>
<SendouButton
size="small"
variant="minimal-destructive"
shape="circle"
icon={<X />}
aria-label="Remove music"
isDisabled={isDisabled}
onClick={() => setMontageMusic(null)}
/>
</>
) : (
<>
<label className={styles.musicButton}>
Choose file
<input
type="file"
accept="audio/*,video/*"
disabled={isDisabled}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) setMontageMusic(file);
}}
/>
</label>
<span className={styles.note}>optional, replaces the game sound</span>
</>
)}
</div>
);
}
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 (
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Clips · {candidates.length}</span>
{groups.length > 0 ? (
<SendouButton
size="small"
variant="minimal"
icon={allCollapsed ? <ChevronsUpDown /> : <ChevronsDownUp />}
onClick={() =>
toggleGroups(
groups.map((group) => String(group.baseWeaponId)),
allCollapsed,
)
}
>
{allCollapsed ? "Expand all" : "Collapse all"}
</SendouButton>
) : null}
</div>
<div className={styles.criteria}>
<span className={styles.criterion}>
<span className={styles.criterionLabel}>Min splats</span>
<SendouChipRadioGroup>
{MONTAGE_KILL_COUNTS.map((kills) => (
<SendouChipRadio
key={kills}
name="montage-min-kills"
value={String(kills)}
checked={montage.criteria.minKills === kills}
onChange={() => updateMontageCriteria({ minKills: kills })}
>
{kills}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
</span>
{MONTAGE_KILL_COUNTS.filter(
(kills) => kills >= montage.criteria.minKills,
).map((kills) => (
<span key={kills} className={styles.criterion}>
<span className={styles.criterionLabel}>
{kills === MONTAGE_KILL_COUNTS.at(-1) ? `${kills}+` : kills}{" "}
splats, max
</span>
<SendouChipRadioGroup>
{MONTAGE_MAX_SECONDS_OPTIONS.map((seconds) => (
<SendouChipRadio
key={seconds}
name={`montage-max-seconds-${kills}`}
value={String(seconds)}
checked={maxSecondsFor(montage.criteria, kills) === seconds}
onChange={() =>
updateMontageCriteria({
maxSecondsByKills: {
...montage.criteria.maxSecondsByKills,
[kills]: seconds,
},
})
}
>
{seconds}s
</SendouChipRadio>
))}
</SendouChipRadioGroup>
</span>
))}
</div>
{candidates.length === 0 ? (
<p className={styles.note}>
{montage.candidates.length === 0
? "No clips yet: they appear as VoDs are scanned."
: "Every team with a clip is in the montage."}
</p>
) : (
groups.map((group) => {
const groupKey = String(group.baseWeaponId);
const isCollapsed = collapsed.has(groupKey);
return (
<div key={groupKey} className={styles.group}>
<button
type="button"
className={styles.groupTitle}
aria-expanded={!isCollapsed}
onClick={() => toggleGroups([groupKey], isCollapsed)}
>
{isCollapsed ? (
<ChevronRight size={16} aria-hidden />
) : (
<ChevronDown size={16} aria-hidden />
)}
{group.baseWeaponId === null ? null : (
<WeaponImage
weaponSplId={group.baseWeaponId}
variant="badge"
size={28}
/>
)}
{mainWeaponLabel(group.baseWeaponId) ?? "Unknown weapon"}
<span className={styles.groupCount}>
{group.candidates.length}
</span>
</button>
{isCollapsed ? null : (
<div className={styles.grid}>
{group.candidates.map((candidate) => (
<CandidateCard
key={candidate.key}
candidate={candidate}
isDisabled={montage.render?.state === "rendering"}
/>
))}
</div>
)}
</div>
);
})
)}
</section>
);
}
function CandidateCard({
candidate,
isDisabled,
}: {
candidate: MontageCandidate;
isDisabled: boolean;
}) {
const thumbnail = useThumbnail(candidate);
const [previewUrl, setPreviewUrl] = useState<string | null>(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 (
<div className={styles.card}>
{previewUrl ? (
// biome-ignore lint/a11y/useMediaCaption: game footage has no captions
<video
className={styles.video}
src={previewUrl}
controls
autoPlay
playsInline
/>
) : (
<button
type="button"
className={styles.thumb}
style={
thumbnail ? { backgroundImage: `url(${thumbnail})` } : undefined
}
onClick={() => void play()}
aria-label="Play clip"
>
<span className={styles.playBadge}>
<Play size={18} aria-hidden />
</span>
</button>
)}
<div className={styles.cardBody}>
<CandidateSummary candidate={candidate} />
<SendouButton
size="small"
variant="outlined"
icon={<Plus />}
isDisabled={isDisabled}
onClick={() => pickCandidate(candidate)}
>
Add
</SendouButton>
</div>
</div>
);
}
function CandidateSummary({ candidate }: { candidate: MontageCandidate }) {
const { window, vod } = candidate;
const where = [modeLabel(candidate.mode), stageLabel(candidate.stage)]
.filter(Boolean)
.join(" · ");
return (
<span className={styles.summary}>
<span className={styles.summaryTitle}>
{candidate.weaponId === null ||
candidate.weaponId === undefined ? null : (
<WeaponImage
weaponSplId={candidate.weaponId}
variant="badge"
size={20}
/>
)}
{window.kills} splats · {vod.team?.name ?? "Cast"}
</span>
<span className={styles.summaryMeta}>
{formatPosition(candidate.duration)}
{candidate.duration < window.end - window.start
? ` (${formatPosition(window.end - window.start)} raw)`
: null}
{where ? ` · ${where}` : null} · {vod.account} · match {vod.matchId}
</span>
</span>
);
}
/** Base weapon ids (as strings, "null" for unknown) of the collapsed clip groups. */
function loadCollapsedGroups(): Set<string> {
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<string>): 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<string | null>(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;
}

View File

@@ -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() {
<ScreenshotPage />
) : view === "fixtures" && process.env.NODE_ENV === "development" ? (
<FixturesPage />
) : view === "montage" && process.env.NODE_ENV === "development" ? (
<MontageView />
) : (
<LandingView />
);

View File

@@ -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({
<br />
Sessions: last 30 days or {MAX_SESSIONS} sessions.
</p>
{!debug || showSaveFrame || showFixturesLink ? (
{!debug || showSaveFrame || showDevLinks ? (
<section className={styles.section}>
<span className={styles.label}>Debug</span>
<div className={styles.row}>
@@ -166,15 +166,25 @@ export function SettingsPopover({
Save frame as fixture
</SendouButton>
) : null}
{showFixturesLink ? (
<Link
to={scannerSearchParams.href(SCANNER_PAGE, {
view: "fixtures",
})}
defaultShouldRevalidate={false}
>
Fixtures
</Link>
{showDevLinks ? (
<>
<Link
to={scannerSearchParams.href(SCANNER_PAGE, {
view: "fixtures",
})}
defaultShouldRevalidate={false}
>
Fixtures
</Link>
<Link
to={scannerSearchParams.href(SCANNER_PAGE, {
view: "montage",
})}
defaultShouldRevalidate={false}
>
Tournament montage
</Link>
</>
) : null}
</div>
</section>

View File

@@ -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<string, readonly MontageGame[]>,
): Map<number, MainWeaponId[]> {
const observations = new Map<number, RunComps.CompObservation[]>();
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<number, MainWeaponId[]>();
for (const [teamId, teamObservations] of observations) {
const comp = RunComps.buildComp(teamObservations);
if (comp.length >= COMP_SIZE) comps.set(teamId, comp);
}
return comps;
}

View File

@@ -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<string, File> | 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<Blob>;
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<string, MontageGame[]>;
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<FileSystemDirectoryHandle>;
/** The permission calls Chromium has on handles but TypeScript's DOM lib lacks. */
type PermissionedDirectory = FileSystemDirectoryHandle & {
queryPermission(descriptor: { mode: "read" }): Promise<PermissionState>;
requestPermission(descriptor: { mode: "read" }): Promise<PermissionState>;
};
type SaveFilePicker = (options: {
suggestedName: string;
types: Array<{ description: string; accept: Record<string, string[]> }>;
}) => Promise<FileSystemFileHandle>;
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<File, string>();
const thumbnails = new Map<string, Promise<string | null>>();
/** one thumbnail decode at a time: a grid of cards would otherwise open every VoD at once */
let thumbnailQueue: Promise<unknown> = 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<void> {
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<void> {
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<void> {
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<MontageCriteria>): 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<string | null> {
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<Blob | null> {
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<void> {
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<void> {
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<number, MainWeaponId[]>();
}
/** 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<void> {
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<void> {
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<string, File>();
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<MontageGame[]> {
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<MontageCriteria> = 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<FileSystemWritableFileStream | null> {
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<MontageSnapshot>): 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();
}

View File

@@ -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<void> {
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);
}
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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<MontageManifestTeam & { placement: number }>;
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<Pick<MontageManifestTeam, "id" | "name" | "logo">>;
/** 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<number, number>;
}
/** 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<MontageGame, "kills" | "deaths">,
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<MontageCandidate, "weaponId">,
>(candidates: readonly T[]) {
const groups = new Map<MainWeaponId | null, T[]>();
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),
);
}

View File

@@ -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"],

View File

@@ -11,6 +11,7 @@ const SCANNER_VIEWS = [
"clips",
"debug",
"fixtures",
"montage",
] as const;
export const scannerSearchParams = SearchParams.define({

View File

@@ -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,

View File

@@ -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<string, MontageGame[]>;
/** 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<void> {
await tx(MONTAGES_STORE, "readwrite", (store) => store.put(montage));
}
export function loadMontage(
tournamentId: number,
): Promise<StoredMontage | undefined> {
return tx(
MONTAGES_STORE,
"readonly",
(store) => store.get(tournamentId) as IDBRequest<StoredMontage | undefined>,
);
}
/** The montage opened most recently. */
export async function latestMontage(): Promise<StoredMontage | undefined> {
const montages = await tx(
MONTAGES_STORE,
"readonly",
(store) => store.getAll() as IDBRequest<StoredMontage[]>,
);
return montages.toSorted((a, b) => b.openedAt - a.openedAt)[0];
}

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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<typeof montageWindows>) =>
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"]],
]);
});
});

View File

@@ -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",

View File

@@ -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/<tournament id>/<match id>-<twitch account>.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`);
}