mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-29 22:58:18 -05:00
Montage PoC
This commit is contained in:
@@ -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
|
||||
|
||||
92
app/features/scanner/capture/montage-overlays.ts
Normal file
92
app/features/scanner/capture/montage-overlays.ts
Normal 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;
|
||||
}
|
||||
401
app/features/scanner/capture/montage-render.ts
Normal file
401
app/features/scanner/capture/montage-render.ts
Normal 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);
|
||||
}
|
||||
114
app/features/scanner/components/MontageGraphics.module.css
Normal file
114
app/features/scanner/components/MontageGraphics.module.css
Normal 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);
|
||||
}
|
||||
306
app/features/scanner/components/MontageGraphics.tsx
Normal file
306
app/features/scanner/components/MontageGraphics.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
250
app/features/scanner/components/MontageView.module.css
Normal file
250
app/features/scanner/components/MontageView.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
638
app/features/scanner/components/MontageView.tsx
Normal file
638
app/features/scanner/components/MontageView.tsx
Normal 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 <id></code>,
|
||||
then open <code>scripts/output/vods/<id></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;
|
||||
}
|
||||
@@ -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 />
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
63
app/features/scanner/components/montage-comps.ts
Normal file
63
app/features/scanner/components/montage-comps.ts
Normal 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;
|
||||
}
|
||||
693
app/features/scanner/components/montage.ts
Normal file
693
app/features/scanner/components/montage.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
210
app/features/scanner/core/loudness.ts
Normal file
210
app/features/scanner/core/loudness.ts
Normal 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;
|
||||
}
|
||||
220
app/features/scanner/core/montage.ts
Normal file
220
app/features/scanner/core/montage.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -11,6 +11,7 @@ const SCANNER_VIEWS = [
|
||||
"clips",
|
||||
"debug",
|
||||
"fixtures",
|
||||
"montage",
|
||||
] as const;
|
||||
|
||||
export const scannerSearchParams = SearchParams.define({
|
||||
|
||||
@@ -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,
|
||||
|
||||
54
app/features/scanner/store/montages.ts
Normal file
54
app/features/scanner/store/montages.ts
Normal 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];
|
||||
}
|
||||
100
app/features/scanner/tests/logic/loudness.test.ts
Normal file
100
app/features/scanner/tests/logic/loudness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
55
app/features/scanner/tests/logic/montage-comps.test.ts
Normal file
55
app/features/scanner/tests/logic/montage-comps.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
app/features/scanner/tests/logic/montage.test.ts
Normal file
132
app/features/scanner/tests/logic/montage.test.ts
Normal 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"]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user