Scanner montage v2

This commit is contained in:
Kalle
2026-10-18 19:19:10 +03:00
parent 1c6c7c34b9
commit fd90f734a7
13 changed files with 1693 additions and 411 deletions

View File

@@ -190,7 +190,7 @@ the ring would have dropped it by then:
builds the basis of a tournament highlight video. `pnpm vods:download <id>`
writes the match VoDs of the top 8 teams (`--all-teams` for every team) to
`scripts/output/vods/<id>/` together with `tournament.json` (name, start,
logo, top 8, each VoD's POV team —
logo, top 8, every team that played, 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
@@ -200,16 +200,20 @@ 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
game's scoreboard seat, variants folded to their base) and can be filtered
by team. Only top teams' candidates are listed: a POV VoD's under its team,
a cast's under either side (`candidateTeamIds`), and each pick is added
under one of them. 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:
re-encodes everything at 1920×1080 60 fps: the intro card (the tournament
and every team's avatar), then the top 8 counting down to the winner
(`montageTeamSections`), each team's card (placement, roster and comp)
followed by its picks in order. The 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
@@ -219,8 +223,13 @@ 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
must stay open. Each card is captured twice, with and without its revealed
items (the intro's team avatars, a team card's roster): the renderer draws
the empty one and brings the items in from the other (`REVEAL_TIMINGS` in
`montage-overlays.ts`), with a moving wave along the bottom. The page's
Cards panel shows each card live with the same animations (to tweak its
styles with hot reload; Replay restarts them) and downloads it as captured. The manifest carries what they show (round names, both
sides, the streamer, rosters, 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

View File

@@ -1,7 +1,9 @@
/**
* 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.
* pre-rendered cards (entering with a fade and a slight settle, a moving
* wave along their bottom, their items revealed one by one —
* `REVEAL_TIMINGS`) and, on a clip, its ribbon on frosted glass (for
* `RIBBON_SECONDS`) and the fast-forward badge.
*/
export const MONTAGE_WIDTH = 1920;
@@ -10,56 +12,264 @@ export const MONTAGE_HEIGHT = 1080;
const CARD_FADE_SECONDS = 0.5;
const CARD_SETTLE_SECONDS = 1.2;
const CARD_SETTLE_ZOOM = 0.03;
/** the band along a card's bottom the wave moves in */
export const CARD_WAVE_HEIGHT = 216;
/** back to front; each layer's crest line is the sum of its sines, drifting at `speed` px/s */
const CARD_WAVE_LAYERS = [
{
color: "oklch(42% 0.15 253)",
base: 70,
sines: [
{ amplitude: 22, length: 900, speed: 60 },
{ amplitude: 10, length: 430, speed: -35 },
],
},
{
color: "oklch(54% 0.18 253)",
base: 115,
sines: [
{ amplitude: 18, length: 700, speed: -80 },
{ amplitude: 8, length: 380, speed: 45 },
],
},
{
color: "oklch(66% 0.17 253)",
base: 160,
sines: [
{ amplitude: 14, length: 1100, speed: 110 },
{ amplitude: 7, length: 300, speed: -50 },
],
},
] as const;
const CARD_WAVE_STEP_PX = 8;
/**
* How a card's items come in: `pop` grows each from its center (the intro's
* team avatars, from the top left to the bottom right), `rise` fades each
* in while sliding up (a team card's roster, in reading order). The first
* item starts `start` seconds into the card, the last one `spread` later,
* each taking `item`.
*/
export const REVEAL_TIMINGS = {
pop: { start: 0.4, spread: 1.6, item: 0.5 },
rise: { start: 0.5, spread: 0.6, item: 0.5 },
} as const;
export const REVEAL_RISE_PX = 40;
const RIBBON_SECONDS = 5;
const RIBBON_FADE_SECONDS = 0.4;
const RIBBON_RISE_PX = 24;
const RIBBON_MARGIN_PX = 40;
export const RIBBON_MARGIN_PX = 40;
/** the frosted glass the ribbon's content sits on, in output px */
export const RIBBON_GLASS = {
blur: 28,
radius: 32,
tint: "oklch(17% 0.03 268 / 0.55)",
border: "oklch(100% 0 0 / 0.16)",
borderWidth: 2,
} as const;
type Context = OffscreenCanvasRenderingContext2D;
type AnyContext = Context | CanvasRenderingContext2D;
/** A full-frame card `t` seconds into its `seconds` on screen. */
/** Where a revealed item sits on the card, in fractions of its width and height. */
export interface CardRevealRect {
x: number;
y: number;
width: number;
height: number;
}
export type RevealStyle = keyof typeof REVEAL_TIMINGS;
/** Items revealed over a card: cut from `image` (the card with them shown) at `rects`, in DOM order. */
export interface CardReveal {
style: RevealStyle;
image: ImageBitmap;
rects: CardRevealRect[];
}
/** A full-frame card `t` seconds into its `seconds` on screen, its `reveal` items coming in over it. */
export function drawCard(
ctx: Context,
card: ImageBitmap,
{ t, seconds }: { t: number; seconds: number },
{ t, seconds, reveal }: { t: number; seconds: number; reveal?: CardReveal },
): 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.save();
ctx.translate(MONTAGE_WIDTH / 2, MONTAGE_HEIGHT / 2);
ctx.scale(zoom, zoom);
ctx.translate(-MONTAGE_WIDTH / 2, -MONTAGE_HEIGHT / 2);
ctx.drawImage(card, 0, 0, MONTAGE_WIDTH, MONTAGE_HEIGHT);
if (reveal) drawReveal(ctx, reveal, t);
ctx.restore();
drawCardWave(ctx, t, MONTAGE_HEIGHT - CARD_WAVE_HEIGHT);
ctx.restore();
}
/** The clip's ribbon, bottom center, `t` seconds into the clip; gone after `RIBBON_SECONDS`. */
/**
* Each item's place in the reveal, 0 (first) to 1 (last): `pop` along the
* diagonal from the top left to the bottom right by their centers, `rise`
* in the order given. Rects in any one unit for both axes.
*/
export function revealOrder(
style: RevealStyle,
rects: ReadonlyArray<{ x: number; y: number; width: number; height: number }>,
): number[] {
if (style === "rise") return riseOrder(rects.length);
const sums = rects.map(
(rect) => rect.x + rect.width / 2 + rect.y + rect.height / 2,
);
const min = Math.min(...sums);
const span = Math.max(...sums) - min;
return sums.map((sum) => (span > 0 ? (sum - min) / span : 0));
}
/** `count` items' places in a `rise` reveal, evenly spaced from 0 (first) to 1 (last). */
export function riseOrder(count: number): number[] {
return Array.from({ length: count }, (_, i) =>
count > 1 ? i / (count - 1) : 0,
);
}
/** How far the item at `order` has come in `t` seconds into the card, 0 to 1. */
function revealProgress(style: RevealStyle, t: number, order: number): number {
const timing = REVEAL_TIMINGS[style];
return Math.min(
1,
Math.max(0, (t - timing.start - order * timing.spread) / timing.item),
);
}
function drawReveal(ctx: Context, reveal: CardReveal, t: number): void {
const { image, style } = reveal;
const rects = reveal.rects.map((rect) => ({
x: rect.x * MONTAGE_WIDTH,
y: rect.y * MONTAGE_HEIGHT,
width: rect.width * MONTAGE_WIDTH,
height: rect.height * MONTAGE_HEIGHT,
source: rect,
}));
const orders = revealOrder(style, rects);
for (const [i, rect] of rects.entries()) {
const progress = revealProgress(style, t, orders[i]!);
if (progress === 0) continue;
const scale = style === "pop" ? easeOutBack(progress) : 1;
const width = rect.width * scale;
const height = rect.height * scale;
const x = rect.x + (rect.width - width) / 2;
const y =
rect.y +
(rect.height - height) / 2 +
(style === "rise" ? (1 - easeOut(progress)) * REVEAL_RISE_PX : 0);
ctx.save();
if (style === "rise") ctx.globalAlpha *= easeOut(progress);
ctx.beginPath();
ctx.roundRect(x, y, width, height, Math.min(width, height) / 2);
ctx.clip();
ctx.drawImage(
image,
rect.source.x * image.width,
rect.source.y * image.height,
rect.source.width * image.width,
rect.source.height * image.height,
x,
y,
width,
height,
);
ctx.restore();
}
}
/** The card's wave `t` seconds in, `CARD_WAVE_HEIGHT` tall from `top`, across the frame's width. */
export function drawCardWave(ctx: AnyContext, t: number, top: number): void {
const bottom = top + CARD_WAVE_HEIGHT;
for (const layer of CARD_WAVE_LAYERS) {
ctx.fillStyle = layer.color;
ctx.beginPath();
ctx.moveTo(0, bottom);
for (let x = 0; x <= MONTAGE_WIDTH; x += CARD_WAVE_STEP_PX) {
const crest = layer.sines.reduce<number>(
(sum, sine) =>
sum +
sine.amplitude *
Math.sin((2 * Math.PI * (x + sine.speed * t)) / sine.length),
layer.base,
);
ctx.lineTo(x, top + crest);
}
ctx.lineTo(MONTAGE_WIDTH, bottom);
ctx.closePath();
ctx.fill();
}
}
/**
* The clip's ribbon (its content on a transparent background), bottom
* center, `t` seconds into the clip, on frosted glass: the frame behind it
* blurred and tinted. 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);
const x = (MONTAGE_WIDTH - ribbon.width) / 2;
const y =
MONTAGE_HEIGHT -
ribbon.height -
RIBBON_MARGIN_PX +
(1 - easeOut(fadeIn)) * RIBBON_RISE_PX;
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.beginPath();
ctx.roundRect(x, y, ribbon.width, ribbon.height, RIBBON_GLASS.radius);
ctx.save();
ctx.clip();
drawBlurred(ctx, { x, y, width: ribbon.width, height: ribbon.height });
ctx.fillStyle = RIBBON_GLASS.tint;
ctx.fillRect(x, y, ribbon.width, ribbon.height);
ctx.drawImage(ribbon, x, y);
ctx.restore();
ctx.strokeStyle = RIBBON_GLASS.border;
ctx.lineWidth = RIBBON_GLASS.borderWidth;
ctx.stroke();
ctx.restore();
}
let blurScratch: OffscreenCanvas | null = null;
/** The canvas's own pixels around the area drawn back over it blurred (a canvas can't filter itself in place). */
function drawBlurred(
ctx: Context,
area: { x: number; y: number; width: number; height: number },
): void {
const pad = RIBBON_GLASS.blur * 2;
const x = Math.max(0, Math.floor(area.x - pad));
const y = Math.max(0, Math.floor(area.y - pad));
const width =
Math.min(MONTAGE_WIDTH, Math.ceil(area.x + area.width + pad)) - x;
const height =
Math.min(MONTAGE_HEIGHT, Math.ceil(area.y + area.height + pad)) - y;
if (
!blurScratch ||
blurScratch.width < width ||
blurScratch.height < height
) {
blurScratch = new OffscreenCanvas(width, height);
}
const scratch = blurScratch.getContext("2d")!;
scratch.clearRect(0, 0, width, height);
scratch.drawImage(ctx.canvas, x, y, width, height, 0, 0, width, height);
ctx.filter = `blur(${RIBBON_GLASS.blur}px)`;
ctx.drawImage(blurScratch, 0, 0, width, height, x, y, width, height);
ctx.filter = "none";
}
/** `▶▶ 4×` on the right, under the special gauge, while dead time is fast-forwarded. */
@@ -90,3 +300,9 @@ export function drawFastForwardBadge(ctx: Context, speed: number): void {
function easeOut(x: number): number {
return 1 - (1 - x) ** 3;
}
/** `cubic-bezier(0.34, 1.56, 0.64, 1)` in CSS */
function easeOutBack(x: number): number {
const overshoot = 1.70158;
return 1 + (overshoot + 1) * (x - 1) ** 3 + overshoot * (x - 1) ** 2;
}

View File

@@ -1,8 +1,8 @@
/**
* 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
* Renders the tournament montage into one MP4, in the browser: its parts in
* order, full-frame cards and the picked clips played back per their
* segments (each with its ribbon; fast-forwarded stretches muted and
* badged). 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
@@ -31,6 +31,7 @@ import {
import { integratedLoudness, limitPeaks } from "../core/loudness";
import { type PlaybackSegment, playbackDuration } from "../core/montage";
import {
type CardReveal,
drawCard,
drawFastForwardBadge,
drawRibbon,
@@ -43,8 +44,6 @@ const FPS = 60;
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 */
@@ -56,7 +55,17 @@ const PEAK_CEILING = 10 ** (-1 / 20);
const MUSIC_FADE_IN_SECONDS = 1;
const MUSIC_FADE_OUT_SECONDS = 3;
export interface MontageClipSource {
export type MontagePart =
| {
kind: "card";
/** full frame */
image: ImageBitmap;
seconds: number;
reveal?: CardReveal;
}
| ({ kind: "clip" } & MontageClipSource);
interface MontageClipSource {
file: File;
segments: PlaybackSegment[];
/** drawn over the clip's first seconds */
@@ -74,19 +83,13 @@ export interface MontageRenderProgress {
* memory, returning the MP4. Throws when `signal` aborts.
*/
export async function renderMontage({
title,
clips,
standings,
parts,
music,
writable,
signal,
onProgress,
}: {
/** full frame */
title: ImageBitmap;
clips: MontageClipSource[];
/** full frame */
standings: ImageBitmap;
parts: MontagePart[];
/** its sound replaces the game sound */
music: File | null;
writable: WritableStream<StreamTargetChunk> | null;
@@ -121,15 +124,7 @@ export async function renderMontage({
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 totalFrames = parts.reduce((sum, part) => sum + framesOf(part), 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 */
@@ -147,11 +142,10 @@ export async function renderMontage({
await output.start();
try {
await renderCard(title, titleFrames);
for (const clip of clips) {
await renderClip(clip);
for (const part of parts) {
if (part.kind === "card") await renderCard(part, framesOf(part));
else await renderClip(part);
}
await renderCard(standings, standingsFrames);
await output.finalize();
} catch (error) {
await output.cancel();
@@ -163,12 +157,16 @@ export async function renderMontage({
: null;
async function renderCard(
card: ImageBitmap,
card: Extract<MontagePart, { kind: "card" }>,
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 });
drawCard(ctx, card.image, {
t: i / FPS,
seconds: frameCount / FPS,
reveal: card.reveal,
});
await addFrame();
}
}
@@ -224,6 +222,13 @@ export async function renderMontage({
}
}
function framesOf(part: MontagePart): number {
return Math.round(
(part.kind === "card" ? part.seconds : playbackDuration(part.segments)) *
FPS,
);
}
/** 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;

View File

@@ -130,11 +130,13 @@ async function copyAudio(
source.close();
}
/** A small JPEG data URL of the frame at `t` seconds, for a clip card; null when the file can't be decoded there. */
/** A JPEG data URL of the frame at `t` seconds, small by default (a clip card's); null when the file can't be decoded there. */
export async function vodFrameThumbnail(
file: File,
t: number,
width = THUMBNAIL_WIDTH,
): Promise<string | null> {
const height = Math.round((width * THUMBNAIL_HEIGHT) / THUMBNAIL_WIDTH);
const input = new Input({
formats: ALL_FORMATS,
source: new BlobSource(file),
@@ -146,15 +148,9 @@ export async function vodFrameThumbnail(
if (!sample) return null;
try {
const canvas = document.createElement("canvas");
canvas.width = THUMBNAIL_WIDTH;
canvas.height = THUMBNAIL_HEIGHT;
sample.draw(
canvas.getContext("2d")!,
0,
0,
THUMBNAIL_WIDTH,
THUMBNAIL_HEIGHT,
);
canvas.width = width;
canvas.height = height;
sample.draw(canvas.getContext("2d")!, 0, 0, width, height);
return canvas.toDataURL("image/jpeg", 0.7);
} finally {
sample.close();

View File

@@ -12,16 +12,18 @@
}
.frame {
position: relative;
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);
background: var(--color-bg);
/* the graphic's own box is a bordered gradient, the cards are flat and open */
& > div {
position: relative;
background: var(--color-bg);
border: none;
}
}
.ribbon {
@@ -29,6 +31,18 @@
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: var(--s-6);
width: 1400px;
padding: var(--s-4) var(--s-6);
color: var(--color-text);
white-space: nowrap;
}
.ribbonLabel {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
letter-spacing: 0.5px;
text-transform: uppercase;
color: color-mix(in oklch, var(--color-text) 65%, transparent);
}
.ribbonRound {
@@ -45,25 +59,22 @@
text-overflow: ellipsis;
}
/* both sides' pills as wide as the wider one */
.ribbonTeams {
display: flex;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--s-3);
}
.ribbonTeam,
.ribbonTeamPov {
.ribbonTeam {
display: flex;
align-items: center;
justify-content: 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);
padding: var(--s-1-5) var(--s-4) var(--s-1-5) var(--s-1-5);
background-color: oklch(0% 0 0 / 0.3);
border-radius: var(--radius-full);
}
.ribbonTeamName {
@@ -78,7 +89,7 @@
font-size: var(--font-xs);
font-weight: var(--weight-extra);
text-transform: uppercase;
color: var(--color-text-high);
color: color-mix(in oklch, var(--color-text) 65%, transparent);
}
.ribbonPov {
@@ -92,23 +103,232 @@
.ribbonPovText {
display: flex;
flex-direction: column;
align-items: flex-end;
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);
font-size: var(--font-lg);
font-weight: var(--weight-extra);
text-overflow: ellipsis;
}
/* a circle the size of the POV avatar beside it, the weapon spilling over its edge */
.ribbonWeapon {
display: flex;
padding: var(--s-1);
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background-color: oklch(0% 0 0 / 0.3);
border-radius: var(--radius-full);
& > * {
flex-shrink: 0;
}
}
.ribbonPreviewFrame {
position: relative;
background: var(--color-bg-high) center / cover no-repeat;
overflow: hidden;
}
/* the renderer's ribbon timing: 0.4 s fade and rise in, out at 5 s, looped with a pause */
.ribbonPreviewGlass {
position: absolute;
inset-inline: 0;
width: fit-content;
margin-inline: auto;
animation: ribbon-loop 6s infinite;
}
@keyframes ribbon-loop {
0% {
opacity: 0;
translate: 0 19px;
}
6.7% {
opacity: 1;
translate: 0 0;
}
76.7% {
opacity: 1;
}
83.3%,
100% {
opacity: 0;
}
}
.preview {
position: relative;
width: fit-content;
max-width: 100%;
overflow-x: auto;
border: var(--border-width) solid var(--color-border-high);
border-radius: var(--radius-box);
}
.previewWave {
position: absolute;
inset: auto 0 0;
width: 100%;
height: auto;
pointer-events: none;
}
.teamWall {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--team-wall-gap);
overflow: hidden;
& img {
width: var(--team-wall-avatar);
height: var(--team-wall-avatar);
}
}
.teamWallItem {
display: block;
}
/* the renderer's reveals (`REVEAL_TIMINGS` in montage-overlays.ts) played back by the preview */
[data-reveal-state="hidden"] [data-reveal] {
visibility: hidden;
}
[data-reveal-state="playing"] [data-reveal="pop"] {
animation: reveal-pop var(--reveal-duration) cubic-bezier(0.34, 1.56, 0.64, 1)
var(--reveal-delay) both;
}
[data-reveal-state="playing"] [data-reveal="rise"] {
animation: reveal-rise var(--reveal-duration) cubic-bezier(0.33, 1, 0.68, 1)
var(--reveal-delay) both;
}
@keyframes reveal-pop {
from {
scale: 0;
}
to {
scale: 1;
}
}
@keyframes reveal-rise {
from {
opacity: 0;
translate: 0 var(--reveal-rise);
}
to {
opacity: 1;
translate: 0 0;
}
}
.teamLeading {
display: flex;
align-items: center;
gap: var(--s-2);
}
.teamSubtitle {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.teamSection {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}
.teamRoster {
display: grid;
gap: var(--s-2);
}
.teamPlayer {
display: flex;
align-items: center;
gap: var(--s-2-5);
min-width: 0;
padding: var(--s-1-5) var(--s-3) var(--s-1-5) var(--s-1-5);
background-color: var(--color-bg-high);
border-radius: var(--radius-full);
}
.teamPlayerName {
overflow: hidden;
font-size: var(--font-md);
font-weight: var(--weight-extra);
text-overflow: ellipsis;
}
.teamWeapons {
display: flex;
justify-content: center;
gap: var(--s-3);
padding: var(--s-2);
background-color: oklch(0% 0 0 / 0.3);
border-radius: var(--radius-field);
}
.teamWeaponKit {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.introTier {
display: inline-flex;
vertical-align: middle;
margin-inline-start: var(--s-2);
}
.introSubtitle {
display: flex;
align-items: center;
gap: var(--s-1-5);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.introPill {
display: flex;
align-items: center;
gap: var(--s-4);
padding: var(--s-1-5) var(--s-3);
font-size: var(--font-md);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
}
.introPillItem {
display: flex;
align-items: center;
gap: var(--s-1-5);
& svg {
width: 1.1em;
height: 1.1em;
color: var(--color-fg-accent);
}
}

View File

@@ -1,31 +1,55 @@
/**
* 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.
* exported result images. The renderer (and the image downloads) 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. The page also shows
* the cards as they are (`MontageCardPreview`) to tweak them by.
*/
import { Link2, User, Users } from "lucide-react";
import * as React from "react";
import { createPortal } from "react-dom";
import { Avatar } from "~/components/Avatar";
import { WeaponImage } from "~/components/Image";
import { Flag } from "~/components/Flag";
import { SpecialWeaponImage, WeaponImage } from "~/components/Image";
import { TierPill } from "~/components/TierPill";
import { weaponParams } from "~/features/build-analyzer/core/utils";
import {
GraphicBoxLabel,
GraphicContainer,
GraphicDateSubtitle,
GraphicFooter,
GraphicHeader,
GraphicPlacementCell,
GraphicSiteUrl,
GraphicStat,
GraphicStatsRow,
GraphicTeamRow,
GraphicTeamsList,
GraphicTitle,
} from "~/features/img-export/components/Graphic";
import { TournamentGraphicHeader } from "~/features/img-export/components/TournamentResultsGraphic";
import { SNAPDOM_CAPTURE_OPTIONS } from "~/features/img-export/img-export-constants";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { tournamentPage } from "~/utils/urls";
import type { MontageManifest, MontageManifestVod } from "../core/montage";
import {
CARD_WAVE_HEIGHT,
type CardRevealRect,
drawCardWave,
MONTAGE_HEIGHT,
MONTAGE_WIDTH,
REVEAL_RISE_PX,
REVEAL_TIMINGS,
type RevealStyle,
RIBBON_GLASS,
RIBBON_MARGIN_PX,
revealOrder,
riseOrder,
} from "../capture/montage-overlays";
import type {
MontageManifest,
MontageManifestTopTeam,
MontageManifestVod,
} from "../core/montage";
import styles from "./MontageGraphics.module.css";
import {
type MontageGraphic,
@@ -34,18 +58,33 @@ import {
} 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 FRAME_SCALE: Record<MontageCard["kind"], number> = {
intro: 2,
team: 2.2,
};
/** share of the frame's height kept clear under the card for the wave (`drawCardWave`) */
const FRAME_WAVE_CLEARANCE = 0.14;
const RIBBON_SCALE = 1.25;
/** the ribbon preview's frame is 1920×1080 divided by this */
const RIBBON_PREVIEW_SCALE = 2;
/** the image export's own scale (ImageExportDialog.tsx) */
const RESULTS_SCALE = 1.75;
const TOP_TEAMS_WITH_PLAYERS_IN_VIDEO = 3;
/** the intro's team avatar wall: its content box and the avatar size range */
const TEAM_WALL_WIDTH = 812;
const TEAM_WALL_HEIGHT = 270;
const TEAM_WALL_GAP = 6;
const TEAM_WALL_MAX_AVATAR = 56;
const TEAM_WALL_MIN_AVATAR = 12;
/** a card's revealed items: `hidden` for the renderer to reveal over the capture, `playing` the reveal in the preview */
type RevealState = "shown" | "hidden" | "playing";
/** The full-frame cards of the video. */
export type MontageCard = Extract<MontageGraphic, { kind: "intro" | "team" }>;
interface CaptureRequest {
graphic: MontageGraphic;
resolve: (image: Blob) => void;
resolve: (captured: { image: Blob; revealRects: CardRevealRect[] }) => void;
reject: (error: unknown) => void;
}
@@ -74,14 +113,19 @@ export function MontageGraphicStage({
// capture once the requested graphic has committed to the DOM
React.useEffect(() => {
if (!request || !graphicRef.current) return;
const { graphic } = request;
const scale =
request.graphic.kind === "ribbon"
graphic.kind === "ribbon"
? RIBBON_SCALE
: request.graphic.kind === "results"
: graphic.kind === "results"
? RESULTS_SCALE
: FRAME_SCALE[request.graphic.kind];
: FRAME_SCALE[graphic.kind];
const rects = revealRects(graphicRef.current);
captureImage(graphicRef.current, scale)
.then(request.resolve, request.reject)
.then(
(image) => request.resolve({ image, revealRects: rects }),
request.reject,
)
// the next request can arrive before this settles: only this one is dropped
.finally(() =>
setRequest((current) => (current === request ? null : current)),
@@ -93,28 +137,19 @@ export function MontageGraphicStage({
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}
/>
) : (
{request.graphic.kind === "results" ? (
<ResultsCard manifest={manifest} comps={comps} />
) : request.graphic.kind === "ribbon" ? (
<RibbonGraphic
vod={request.graphic.vod}
weaponId={request.graphic.weaponId}
/>
) : (
<CardGraphic
card={request.graphic}
manifest={manifest}
comps={comps}
/>
)}
</div>
</div>,
@@ -122,53 +157,340 @@ export function MontageGraphicStage({
);
}
function TitleGraphic({ manifest }: { manifest: MontageManifest }) {
/** The card as the video shows it, at its CSS size with the wave moving, to tweak it by. */
export function MontageCardPreview(props: {
card: MontageCard;
manifest: MontageManifest;
comps: ReadonlyMap<number, MainWeaponId[]>;
}) {
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>
<div className={styles.preview} data-theme="dark">
<CardGraphic {...props} isPreview />
<CardWave />
</div>
);
}
/**
* The ribbon over a frame of gameplay as the video shows it: the glass is
* CSS here (`backdrop-filter`), drawn per frame by the renderer
* (`RIBBON_GLASS`), and the rise and fade loop.
*/
export function MontageRibbonPreview({
vod,
weaponId,
background,
}: {
vod: MontageManifestVod;
weaponId: MainWeaponId | null;
/** a frame of the clip, else a plain backdrop */
background: string | null;
}) {
return (
<div className={styles.preview} data-theme="dark">
<div
className={styles.ribbonPreviewFrame}
style={{
width: MONTAGE_WIDTH / RIBBON_PREVIEW_SCALE,
height: MONTAGE_HEIGHT / RIBBON_PREVIEW_SCALE,
backgroundImage: background ? `url(${background})` : undefined,
}}
>
<div
className={styles.ribbonPreviewGlass}
style={{
zoom: RIBBON_SCALE / RIBBON_PREVIEW_SCALE,
bottom: RIBBON_MARGIN_PX / RIBBON_SCALE,
backdropFilter: `blur(${RIBBON_GLASS.blur / RIBBON_SCALE}px)`,
backgroundColor: RIBBON_GLASS.tint,
border: `${RIBBON_GLASS.borderWidth / RIBBON_SCALE}px solid ${RIBBON_GLASS.border}`,
borderRadius: RIBBON_GLASS.radius / RIBBON_SCALE,
}}
>
<RibbonGraphic vod={vod} weaponId={weaponId} />
</div>
</div>
</div>
);
}
/** The wave as the renderer draws it over the card, played back in real time. */
function CardWave() {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
// drawn per animation frame, like the renderer draws it per video frame
React.useEffect(() => {
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
let frame = 0;
const startedAt = performance.now();
const draw = (now: number) => {
ctx.clearRect(0, 0, MONTAGE_WIDTH, CARD_WAVE_HEIGHT);
drawCardWave(ctx, (now - startedAt) / 1000, 0);
frame = requestAnimationFrame(draw);
};
frame = requestAnimationFrame(draw);
return () => cancelAnimationFrame(frame);
}, []);
return (
<canvas
ref={canvasRef}
className={styles.previewWave}
width={MONTAGE_WIDTH}
height={CARD_WAVE_HEIGHT}
aria-hidden
/>
);
}
function CardGraphic({
card,
manifest,
comps,
isPreview = false,
}: {
card: MontageCard;
manifest: MontageManifest;
comps: ReadonlyMap<number, MainWeaponId[]>;
isPreview?: boolean;
}) {
const team =
card.kind === "team"
? manifest.topTeams.find((candidate) => candidate.id === card.teamId)
: undefined;
const reveal: RevealState = isPreview
? "playing"
: card.hideRevealed
? "hidden"
: "shown";
return (
<Frame scale={FRAME_SCALE[card.kind]}>
{team ? (
<TeamGraphic
manifest={manifest}
team={team}
weapons={comps.get(team.id) ?? []}
reveal={reveal}
/>
) : (
<IntroGraphic manifest={manifest} reveal={reveal} />
)}
</Frame>
);
}
/** The results image export's layout: the top teams with their comps, rosters for the first `rosterCount`. */
/** The tournament and every team that played, their avatars popping in. */
function IntroGraphic({
manifest,
reveal,
}: {
manifest: MontageManifest;
reveal: RevealState;
}) {
const wall = teamWallLayout(manifest.teams.length);
const orders = revealOrder(
"pop",
manifest.teams.map((_, i) =>
wallCell(i, wall.perRow, manifest.teams.length),
),
);
return (
<GraphicContainer width={TEAM_WALL_WIDTH + 48}>
<GraphicHeader
leading={
<Avatar
url={montageImageUrl(manifest.logo)}
identiconInput={manifest.name}
size="md"
loading="eager"
/>
}
titleRow={
<GraphicTitle>
{manifest.name}
{typeof manifest.tier === "number" ? (
<span className={styles.introTier}>
<TierPill tier={manifest.tier} withoutAnimation />
</span>
) : null}
</GraphicTitle>
}
subtitle={
<div className={styles.introSubtitle}>
<GraphicDateSubtitle date={new Date(manifest.startsAt)} />
{manifest.organization ? (
<>
<span>·</span>
<Avatar
url={montageImageUrl(manifest.organization.logo)}
identiconInput={manifest.organization.name}
size="xxxsm"
loading="eager"
/>
<span>{manifest.organization.name}</span>
</>
) : null}
</div>
}
trailing={
<>
<div className={styles.introPill}>
<span className={styles.introPillItem}>
<Link2 aria-hidden />
<GraphicSiteUrl path={tournamentPage(manifest.tournamentId)} />
</span>
</div>
<div className={styles.introPill}>
<span className={styles.introPillItem}>
<Users aria-label="Teams" />
{manifest.teamsCount}
</span>
<span className={styles.introPillItem}>
<User aria-label="Players" />
{manifest.playersCount}
</span>
</div>
</>
}
/>
<div
className={styles.teamWall}
data-reveal-state={reveal}
style={
{
"--team-wall-avatar": `${wall.avatarSize}px`,
"--team-wall-gap": `${TEAM_WALL_GAP}px`,
maxHeight: TEAM_WALL_HEIGHT,
} as React.CSSProperties
}
>
{manifest.teams.map((team, i) => (
<span
key={team.id}
className={styles.teamWallItem}
data-reveal="pop"
style={revealItemStyle("pop", orders[i]!, FRAME_SCALE.intro)}
>
<Avatar
url={montageImageUrl(team.logo)}
identiconInput={team.name}
size="xs"
loading="eager"
/>
</span>
))}
</div>
</GraphicContainer>
);
}
/** A top team's introduction: placement, roster and the comp the scans saw. */
function TeamGraphic({
manifest,
team,
weapons,
reveal,
}: {
manifest: MontageManifest;
team: MontageManifestTopTeam;
weapons: MainWeaponId[];
/** the roster's, rising in */
reveal: RevealState;
}) {
const orders = riseOrder(team.players.length);
return (
<GraphicContainer width={720}>
<GraphicHeader
leading={
<div className={styles.teamLeading}>
<GraphicPlacementCell placement={team.placement} />
<Avatar
url={montageImageUrl(team.logo)}
identiconInput={team.name}
size="xsm"
loading="eager"
/>
</div>
}
titleRow={<GraphicTitle>{team.name}</GraphicTitle>}
subtitle={<div className={styles.teamSubtitle}>{manifest.name}</div>}
/>
<div className={styles.teamSection}>
<div
className={styles.teamRoster}
data-reveal-state={reveal}
style={{
gridTemplateColumns: `repeat(${team.players.length > 4 ? 3 : 2}, minmax(0, 1fr))`,
}}
>
{team.players.map((player, i) => (
<div
key={player.name}
className={styles.teamPlayer}
data-reveal="rise"
style={revealItemStyle("rise", orders[i]!, FRAME_SCALE.team)}
>
<Avatar
url={montageImageUrl(player.avatar)}
identiconInput={player.name}
size="xs"
loading="eager"
/>
<span className={styles.teamPlayerName}>{player.name}</span>
{player.countryCode ? (
<Flag countryCode={player.countryCode} tiny />
) : null}
</div>
))}
</div>
</div>
{weapons.length > 0 ? (
<div className={styles.teamSection}>
<div className={styles.teamWeapons}>
{weapons.map((weaponId, i) => (
<div key={`${weaponId}-${i}`} className={styles.teamWeaponKit}>
<WeaponImage weaponSplId={weaponId} variant="badge" size={56} />
<SpecialWeaponImage
specialWeaponId={
weaponParams().weaponKits[weaponId].specialWeaponId
}
size={32}
/>
</div>
))}
</div>
</div>
) : null}
</GraphicContainer>
);
}
/** The results image export's layout: every top team with its roster and comp. */
function ResultsCard({
manifest,
comps,
rosterCount,
width,
}: {
manifest: MontageManifest;
comps: ReadonlyMap<number, MainWeaponId[]>;
rosterCount: number;
width?: number;
}) {
return (
<GraphicContainer width={width}>
<GraphicContainer>
<Header manifest={manifest} />
<GraphicTeamsList>
{manifest.topTeams.map((team, i) => (
{manifest.topTeams.map((team) => (
<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,
}))
: [],
players: team.players.map((player) => ({
name: player.name,
countryCode: player.countryCode ?? undefined,
})),
weapons: comps.get(team.id) ?? [],
}}
highlighted={team.placement === 1}
@@ -186,6 +508,10 @@ function ResultsCard({
);
}
/**
* The clip's match, both sides and whose POV it is; transparent, as the
* renderer puts it on frosted glass (`drawRibbon`).
*/
function RibbonGraphic({
vod,
weaponId,
@@ -194,60 +520,58 @@ function RibbonGraphic({
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 className={styles.ribbon}>
<div className={styles.ribbonRound}>
<div className={styles.ribbonLabel}>{vod.bracketName ?? "Match"}</div>
<div className={styles.ribbonRoundName}>
{vod.roundName ?? `Match ${vod.matchId}`}
</div>
</div>
</GraphicContainer>
<div className={styles.ribbonTeams}>
{povTeamFirst(vod).map((team, i) => (
<React.Fragment key={team.id}>
{i > 0 ? <span className={styles.ribbonVersus}>vs</span> : null}
<span className={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}>
<div className={styles.ribbonPovText}>
<div className={styles.ribbonLabel}>{vod.pov ? "POV" : "Cast"}</div>
<div className={styles.ribbonPovName}>
{vod.pov?.name ?? `twitch.tv/${vod.account}`}
</div>
</div>
{vod.pov ? (
<Avatar
url={montageImageUrl(vod.pov.avatar)}
identiconInput={vod.pov.name}
size="sm"
loading="eager"
/>
) : null}
{weaponId === null ? null : (
<div className={styles.ribbonWeapon}>
<WeaponImage weaponSplId={weaponId} variant="badge" size={54} />
</div>
)}
</div>
</div>
);
}
/** The match's sides, the POV team on the left. */
function povTeamFirst(vod: MontageManifestVod) {
return vod.teams.toSorted(
(a, b) => Number(b.id === vod.team?.id) - Number(a.id === vod.team?.id),
);
}
@@ -270,7 +594,7 @@ function Header({ manifest }: { manifest: MontageManifest }) {
);
}
/** A 1920×1080 frame at `scale`, the graphic centered on the export's backdrop. */
/** A 1920×1080 frame at `scale`, the graphic centered above the band the renderer draws its wave in. */
function Frame({
scale,
children,
@@ -281,13 +605,74 @@ function Frame({
return (
<div
className={styles.frame}
style={{ width: 1920 / scale, height: 1080 / scale }}
style={{
width: 1920 / scale,
height: 1080 / scale,
paddingBottom: (1080 / scale) * FRAME_WAVE_CLEARANCE,
}}
>
{children}
</div>
);
}
/** The biggest avatar size that fits every team in the wall, and how many go in a row. */
function teamWallLayout(teamCount: number) {
const perRowFor = (size: number) =>
Math.floor((TEAM_WALL_WIDTH + TEAM_WALL_GAP) / (size + TEAM_WALL_GAP));
for (let size = TEAM_WALL_MAX_AVATAR; size > TEAM_WALL_MIN_AVATAR; size--) {
const perRow = perRowFor(size);
const rows = Math.ceil(teamCount / perRow);
if (rows * (size + TEAM_WALL_GAP) - TEAM_WALL_GAP <= TEAM_WALL_HEIGHT) {
return { avatarSize: size, perRow };
}
}
return {
avatarSize: TEAM_WALL_MIN_AVATAR,
perRow: perRowFor(TEAM_WALL_MIN_AVATAR),
};
}
/** The avatar's cell in the wall, in avatar widths: rows wrap at `perRow`, the last one centered. */
function wallCell(index: number, perRow: number, count: number) {
const row = Math.floor(index / perRow);
const inRow = Math.min(perRow, count - row * perRow);
return {
x: (perRow - inRow) / 2 + (index % perRow),
y: row,
width: 1,
height: 1,
};
}
/** The preview's CSS animation timing for the item at `order`, matching the renderer's (`REVEAL_TIMINGS`). */
function revealItemStyle(
style: RevealStyle,
order: number,
scale: number,
): React.CSSProperties {
const timing = REVEAL_TIMINGS[style];
return {
"--reveal-delay": `${timing.start + order * timing.spread}s`,
"--reveal-duration": `${timing.item}s`,
"--reveal-rise": `${REVEAL_RISE_PX / scale}px`,
} as React.CSSProperties;
}
/** Where the graphic's revealed items are, in fractions of its size, in DOM order. */
function revealRects(graphic: HTMLElement): CardRevealRect[] {
const bounds = graphic.getBoundingClientRect();
return [...graphic.querySelectorAll("[data-reveal]")].map((item) => {
const rect = item.getBoundingClientRect();
return {
x: (rect.left - bounds.left) / bounds.width,
y: (rect.top - bounds.top) / bounds.height,
width: rect.width / bounds.width,
height: rect.height / bounds.height,
};
});
}
async function captureImage(
element: HTMLElement,
scale: number,

View File

@@ -248,3 +248,30 @@
display: none;
}
}
.teamSection {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.teamLabel {
display: flex;
align-items: center;
gap: var(--s-1-5);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
.teamPlacement {
min-width: 2rem;
color: var(--color-text-high);
font-variant-numeric: tabular-nums;
}
.addButtons {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-1);
}

View File

@@ -1,10 +1,11 @@
/**
* 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.
* it, then pick clips — the best first, filterable by team — for the top
* teams and render one video: the intro card, then each top team counting
* down to the winner, its card followed by its clips. The cards can be
* previewed on the page. 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,
@@ -20,17 +21,20 @@ import {
Play,
Plus,
RefreshCw,
RotateCcw,
ScanSearch,
Square,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { WeaponImage } from "~/components/Image";
import { Placement } from "~/components/Placement";
import { usePersistedState } from "~/modules/persisted-state/hooks";
import { formatPosition } from "../core/format";
import { mainWeaponLabel, modeLabel, stageLabel } from "../core/labels";
@@ -39,21 +43,32 @@ import {
MONTAGE_KILL_COUNTS,
MONTAGE_MAX_SECONDS_OPTIONS,
type MontageCandidate,
type MontageManifestTopTeam,
type MontageManifestVod,
maxSecondsFor,
montageDuration,
montageTeamSections,
} from "../core/montage";
import { montageCollapsedGroupsPersisted } from "../scanner-persisted";
import { ClipVideo } from "./ClipVideo";
import { MontageGraphicStage } from "./MontageGraphics";
import {
type MontageCard,
MontageCardPreview,
MontageGraphicStage,
MontageRibbonPreview,
} from "./MontageGraphics";
import styles from "./MontageView.module.css";
import {
availableCandidates,
cancelMontageRender,
cancelMontageScan,
candidatePreview,
candidatePreviewFrame,
candidateThumbnail,
downloadMontageResults,
downloadMontageGraphic,
type MontageGraphic,
type MontageSnapshot,
montageDuration,
montageImageUrl,
montageTeamComps,
movePick,
openMontageFolder,
@@ -88,7 +103,14 @@ export function MontageView() {
Reconnect folder
</SendouButton>
) : null}
{montage.folder?.files ? <ResultsImageButton /> : null}
{montage.folder?.files ? (
<GraphicDownloadButton
graphic={{ kind: "results" }}
name="results"
>
Results image
</GraphicDownloadButton>
) : null}
<SendouButton
size="small"
variant={disconnected ? "outlined" : "primary"}
@@ -123,6 +145,7 @@ export function MontageView() {
{montage.folder ? (
<>
<ScanPanel montage={montage} />
<CardsPanel montage={montage} />
<PicksPanel montage={montage} />
<CandidatesPanel montage={montage} />
<MontageGraphicStage
@@ -135,13 +158,24 @@ export function MontageView() {
);
}
function ResultsImageButton() {
/** Downloads the graphic as captured for the video. */
function GraphicDownloadButton({
graphic,
name,
isDisabled = false,
children,
}: {
graphic: MontageGraphic;
name: string;
isDisabled?: boolean;
children: React.ReactNode;
}) {
const [state, setState] = useState<"idle" | "capturing" | "failed">("idle");
const download = async () => {
setState("capturing");
try {
await downloadMontageResults();
await downloadMontageGraphic(graphic, name);
setState("idle");
} catch {
setState("failed");
@@ -153,14 +187,127 @@ function ResultsImageButton() {
size="small"
variant="outlined"
icon={<ImageDown />}
isDisabled={state === "capturing"}
isDisabled={isDisabled || state === "capturing"}
onClick={() => void download()}
>
{state === "failed" ? "Results image failed, retry" : "Results image"}
{state === "failed" ? "Failed, retry" : children}
</SendouButton>
);
}
/** The video's cards as they are, to tweak them by. */
function CardsPanel({ montage }: { montage: MontageSnapshot }) {
const [cardKey, setCardKey] = useState("intro");
const [replayCount, setReplayCount] = useState(0);
const manifest = montage.folder!.manifest;
const teams = manifest.topTeams.toReversed();
const team = teams.find((candidate) => String(candidate.id) === cardKey);
const card: MontageCard = team
? { kind: "team", teamId: team.id }
: { kind: "intro" };
const ribbonSample = montage.picks[0] ?? availableCandidates(montage)[0];
const ribbonVod =
ribbonSample?.vod ??
manifest.vods.find((vod) => vod.pov) ??
manifest.vods[0];
const showsRibbon = cardKey === "ribbon" && ribbonVod !== undefined;
const graphic: MontageGraphic = showsRibbon
? {
kind: "ribbon",
vod: ribbonVod,
weaponId: ribbonSample?.weaponId ?? null,
}
: card;
return (
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Cards</span>
<SendouButton
size="small"
variant="minimal"
icon={<RotateCcw />}
className="ml-auto"
onClick={() => setReplayCount((count) => count + 1)}
>
Replay
</SendouButton>
<GraphicDownloadButton
graphic={graphic}
name={showsRibbon ? "ribbon" : team ? `team-${team.id}` : "intro"}
isDisabled={!montage.folder?.files}
>
PNG
</GraphicDownloadButton>
</div>
<SendouChipRadioGroup wrap>
<SendouChipRadio
name="montage-card"
value="intro"
checked={cardKey === "intro"}
onChange={setCardKey}
>
Intro
</SendouChipRadio>
{teams.map((candidate) => (
<SendouChipRadio
key={candidate.id}
name="montage-card"
value={String(candidate.id)}
checked={candidate === team}
onChange={setCardKey}
>
<Placement placement={candidate.placement} plain /> {candidate.name}
</SendouChipRadio>
))}
{ribbonVod ? (
<SendouChipRadio
name="montage-card"
value="ribbon"
checked={showsRibbon}
onChange={setCardKey}
>
POV ribbon
</SendouChipRadio>
) : null}
</SendouChipRadioGroup>
{showsRibbon ? (
<RibbonPreview
key={replayCount}
sample={ribbonSample}
vod={ribbonVod}
/>
) : (
<MontageCardPreview
key={`${cardKey}-${replayCount}`}
card={card}
manifest={manifest}
comps={montageTeamComps(montage)}
/>
)}
</section>
);
}
/** The ribbon over the sample clip's frame (the first pick or best candidate), when there is one. */
function RibbonPreview({
sample,
vod,
}: {
sample: MontageCandidate | undefined;
vod: MontageManifestVod;
}) {
const background = useCandidateFrame(sample, candidatePreviewFrame);
return (
<MontageRibbonPreview
vod={vod}
weaponId={sample?.weaponId ?? null}
background={background}
/>
);
}
function ScanPanel({ montage }: { montage: MontageSnapshot }) {
const vodScan = useVodScan();
const vods = montage.folder?.vods ?? [];
@@ -223,13 +370,14 @@ function PicksPanel({ montage }: { montage: MontageSnapshot }) {
const picks = montage.picks;
const render = montage.render;
const rendering = render?.state === "rendering";
const topTeams = montage.folder?.manifest.topTeams ?? [];
return (
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
Montage · {picks.length} {picks.length === 1 ? "clip" : "clips"} ·{" "}
{formatPosition(montageDuration(picks))}
{formatPosition(montageDuration(topTeams.length, picks))}
</span>
{rendering ? (
<SendouButton
@@ -244,7 +392,7 @@ function PicksPanel({ montage }: { montage: MontageSnapshot }) {
<SendouButton
size="small"
icon={<Film />}
isDisabled={picks.length === 0 || !montage.folder?.files}
isDisabled={!montage.folder?.files}
onClick={() => void renderMontageVideo()}
>
Render video
@@ -266,54 +414,76 @@ function PicksPanel({ montage }: { montage: MontageSnapshot }) {
)
) : 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>
)}
<p className={styles.note}>
The intro, then the top {topTeams.length} counting down to the winner:
each team's card, then its clips in this order.
</p>
{montageTeamSections(topTeams, picks).map((section) => (
<div key={section.team.id} className={styles.teamSection}>
<TeamLabel team={section.team} />
{section.picks.length === 0 ? (
<p className={styles.note}>No clips, only the card</p>
) : (
<ol className={styles.picks}>
{section.picks.map((pick, i) => (
<li key={pick.key} className={styles.pick}>
<span className={styles.pickIndex}>{i + 1}</span>
<CandidateSummary candidate={pick} />
<span className={styles.pickActions}>
<SendouButton
size="small"
variant="minimal"
shape="circle"
icon={<ArrowUp />}
aria-label="Move earlier"
isDisabled={rendering || i === 0}
onClick={() => movePick(pick.key, -1)}
/>
<SendouButton
size="small"
variant="minimal"
shape="circle"
icon={<ArrowDown />}
aria-label="Move later"
isDisabled={rendering || i === section.picks.length - 1}
onClick={() => movePick(pick.key, 1)}
/>
<SendouButton
size="small"
variant="minimal-destructive"
shape="circle"
icon={<X />}
aria-label="Remove from montage"
isDisabled={rendering}
onClick={() => unpickCandidate(pick.key)}
/>
</span>
</li>
))}
</ol>
)}
</div>
))}
</section>
);
}
function TeamLabel({ team }: { team: MontageManifestTopTeam }) {
return (
<span className={styles.teamLabel}>
<span className={styles.teamPlacement}>
<Placement placement={team.placement} plain />
</span>
<Avatar
url={montageImageUrl(team.logo)}
identiconInput={team.name}
size="xxxsm"
/>
{team.name}
</span>
);
}
/** Optional: a music or video file whose sound replaces the game sound. */
function MusicPicker({
music,
@@ -362,7 +532,13 @@ function MusicPicker({
}
function CandidatesPanel({ montage }: { montage: MontageSnapshot }) {
const candidates = availableCandidates(montage);
const [teamFilter, setTeamFilter] = useState<number | null>(null);
const topTeams = montage.folder?.manifest.topTeams ?? [];
const available = availableCandidates(montage);
const candidates =
teamFilter === null
? available
: available.filter((candidate) => candidate.teamIds.includes(teamFilter));
const groups = candidatesByBaseWeapon(candidates);
const [collapsedGroups, setCollapsedGroups] = usePersistedState(
montageCollapsedGroupsPersisted,
@@ -402,6 +578,35 @@ function CandidatesPanel({ montage }: { montage: MontageSnapshot }) {
) : null}
</div>
<div className={styles.criteria}>
<span className={styles.criterion}>
<span className={styles.criterionLabel}>Team</span>
<SendouChipRadioGroup wrap>
<SendouChipRadio
name="montage-team-filter"
value="all"
checked={teamFilter === null}
onChange={() => setTeamFilter(null)}
>
All · {available.length}
</SendouChipRadio>
{topTeams.map((team) => (
<SendouChipRadio
key={team.id}
name="montage-team-filter"
value={String(team.id)}
checked={teamFilter === team.id}
onChange={() => setTeamFilter(team.id)}
>
{team.name} ·{" "}
{
available.filter((candidate) =>
candidate.teamIds.includes(team.id),
).length
}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
</span>
<span className={styles.criterion}>
<span className={styles.criterionLabel}>Min splats</span>
<SendouChipRadioGroup>
@@ -453,7 +658,7 @@ function CandidatesPanel({ montage }: { montage: MontageSnapshot }) {
<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."}
: "No clips left to pick."}
</p>
) : (
groups.map((group) => {
@@ -491,6 +696,11 @@ function CandidatesPanel({ montage }: { montage: MontageSnapshot }) {
<CandidateCard
key={candidate.key}
candidate={candidate}
teams={topTeams.filter((team) =>
teamFilter === null
? candidate.teamIds.includes(team.id)
: team.id === teamFilter,
)}
isDisabled={montage.render?.state === "rendering"}
/>
))}
@@ -506,12 +716,15 @@ function CandidatesPanel({ montage }: { montage: MontageSnapshot }) {
function CandidateCard({
candidate,
teams,
isDisabled,
}: {
candidate: MontageCandidate;
/** the teams it can be added under, a button each */
teams: MontageManifestTopTeam[];
isDisabled: boolean;
}) {
const thumbnail = useThumbnail(candidate);
const thumbnail = useCandidateFrame(candidate, candidateThumbnail);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// the preview's URL is released with the card
@@ -548,15 +761,20 @@ function CandidateCard({
)}
<div className={styles.cardBody}>
<CandidateSummary candidate={candidate} />
<SendouButton
size="small"
variant="outlined"
icon={<Plus />}
isDisabled={isDisabled}
onClick={() => pickCandidate(candidate)}
>
Add
</SendouButton>
<span className={styles.addButtons}>
{teams.map((team) => (
<SendouButton
key={team.id}
size="small"
variant="outlined"
icon={<Plus />}
isDisabled={isDisabled}
onClick={() => pickCandidate(candidate, team.id)}
>
{teams.length === 1 ? "Add" : team.name}
</SendouButton>
))}
</span>
</div>
</div>
);
@@ -592,20 +810,24 @@ function CandidateSummary({ candidate }: { candidate: MontageCandidate }) {
);
}
/** 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);
/** A frame of the candidate once decoded (they queue up, one VoD open at a time). */
function useCandidateFrame(
candidate: MontageCandidate | undefined,
decode: (candidate: MontageCandidate) => Promise<string | null>,
): string | null {
const [frame, setFrame] = useState<string | null>(null);
// decoding a frame is async work outside React
useEffect(() => {
if (!candidate) return;
let stale = false;
void candidateThumbnail(candidate).then((next) => {
if (!stale) setThumbnail(next);
void decode(candidate).then((next) => {
if (!stale) setFrame(next);
});
return () => {
stale = true;
};
}, [candidate]);
}, [candidate, decode]);
return thumbnail;
return frame;
}

View File

@@ -5,13 +5,13 @@
* 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
* candidates of the top teams they can play under (a cast's go under
* either side). Each pick plays under one team's card. 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
* stitches the intro card, then each top team's card and picks counting
* down to the winner (`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.
@@ -20,8 +20,9 @@ import { useSyncExternalStore } from "react";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
import { downloadBlob } from "~/utils/download";
import type { CardRevealRect, RevealStyle } from "../capture/montage-overlays";
import {
type MontageClipSource,
type MontagePart,
type MontageRenderProgress,
renderMontage,
} from "../capture/montage-render";
@@ -29,16 +30,22 @@ import { extractVodClip, vodFrameThumbnail } from "../capture/vod-clips";
import { povDeathTimes } from "../core/clips/scoring";
import { buildScannerMatches } from "../core/match-builder";
import {
candidateTeamIds,
DEFAULT_MONTAGE_CRITERIA,
INTRO_CARD_SECONDS,
MONTAGE_MANIFEST_FILE,
type MontageCandidate,
type MontageCriteria,
type MontageGame,
type MontageManifest,
type MontageManifestVod,
type MontagePick,
montageDuration,
montageTeamSections,
montageWindows,
playbackDuration,
playbackSegments,
TEAM_CARD_SECONDS,
} from "../core/montage";
import type { ScannerMatch } from "../core/scanner-match";
import { montageCriteriaPersisted } from "../scanner-persisted";
@@ -68,8 +75,10 @@ export type MontageScan =
/** What the page draws for the video; see `MontageGraphics.tsx`. */
export type MontageGraphic =
| { kind: "title" }
| { kind: "standings" }
/** `hideRevealed`: the team avatars' places left empty, for the renderer to reveal them in */
| { kind: "intro"; hideRevealed?: boolean }
/** a top team's card: placement, roster and comp; `hideRevealed` as for the intro, the roster's */
| { kind: "team"; teamId: number; hideRevealed?: boolean }
/** the results image on its own: every top team's roster and comp */
| { kind: "results" }
| {
@@ -78,8 +87,11 @@ export type MontageGraphic =
weaponId: MainWeaponId | null;
};
/** resolves to the graphic as a PNG */
type GraphicCapture = (graphic: MontageGraphic) => Promise<Blob>;
/** resolves to the graphic as a PNG, with where its revealed items are */
type GraphicCapture = (graphic: MontageGraphic) => Promise<{
image: Blob;
revealRects: CardRevealRect[];
}>;
export type MontageRender =
| ({ state: "rendering" } & MontageRenderProgress)
@@ -93,8 +105,8 @@ export interface MontageSnapshot {
criteria: MontageCriteria;
/** every candidate meeting the criteria, best first */
candidates: MontageCandidate[];
/** in montage order */
picks: MontageCandidate[];
/** in montage order within each team */
picks: MontagePick[];
/** plays under the whole video instead of the game sound */
music: File | null;
scan: MontageScan | null;
@@ -117,6 +129,8 @@ type SaveFilePicker = (options: {
types: Array<{ description: string; accept: Record<string, string[]> }>;
}) => Promise<FileSystemFileHandle>;
const PREVIEW_FRAME_WIDTH = 960;
const EMPTY: MontageSnapshot = {
folder: null,
folderError: null,
@@ -146,14 +160,10 @@ export function useMontage(): MontageSnapshot {
return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY);
}
/** Candidates still pickable: none from a team (or a cast VoD) already picked. */
/** Candidates not picked yet. */
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)),
);
const pickedKeys = new Set(state.picks.map((pick) => pick.key));
return state.candidates.filter((candidate) => !pickedKeys.has(candidate.key));
}
/** Opens a folder written by `pnpm vods:download` through the directory picker. */
@@ -238,42 +248,46 @@ export function setMontageMusic(music: File | null): void {
persist();
}
export function pickCandidate(candidate: MontageCandidate): void {
setPicks([...snapshot.picks, candidate]);
/** Adds the candidate after the team's other picks. */
export function pickCandidate(
candidate: MontageCandidate,
teamId: number,
): void {
setPicks([...snapshot.picks, { ...candidate, teamId }]);
}
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]!,
/** Swaps the pick with its neighbor among its team's picks, earlier (-1) or later (1). */
export function movePick(key: string, delta: -1 | 1): void {
const pick = snapshot.picks.find((candidate) => candidate.key === key);
if (!pick) return;
const teamPicks = snapshot.picks.filter(
(candidate) => candidate.teamId === pick.teamId,
);
const neighbor = teamPicks[teamPicks.indexOf(pick) + delta];
if (!neighbor) return;
setPicks(
snapshot.picks.map((candidate) =>
candidate === pick ? neighbor : candidate === neighbor ? pick : candidate,
),
);
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;
return candidateFrame(candidate, undefined);
}
/** The candidate's last kill at `PREVIEW_FRAME_WIDTH`, for the ribbon preview to sit on. */
export function candidatePreviewFrame(
candidate: MontageCandidate,
): Promise<string | null> {
return candidateFrame(candidate, PREVIEW_FRAME_WIDTH);
}
/** The candidate's footage as it is in the VoD (no fast-forward), for watching before picking. */
@@ -291,7 +305,7 @@ export async function candidatePreview(
}
/**
* Renders the picks into an MP4, saved where the user picks (Chrome's save
* Renders the cards and 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.
*/
@@ -299,16 +313,10 @@ 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;
}
if (!folder || !files || snapshot.render?.state === "rendering") return;
const { manifest } = folder;
const fileName = `${fileSlug(manifest)}-montage.mp4`;
const total = montageDuration(manifest.topTeams.length, picks);
let writable: FileSystemWritableFileStream | null;
try {
@@ -320,39 +328,58 @@ export async function renderMontageVideo(): Promise<void> {
const controller = new AbortController();
renderAbort = controller;
set({
render: {
state: "rendering",
done: 0,
total: montageDuration(picks),
},
render: { state: "rendering", done: 0, total },
});
try {
const captureBlob = captureGraphic;
if (!captureBlob) throw new Error("the montage view isn't mounted");
const captureCurrent = captureGraphic;
if (!captureCurrent) 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`);
createImageBitmap((await captureCurrent(graphic)).image);
const captureCard = async (
card: Extract<MontageGraphic, { kind: "intro" | "team" }>,
style: RevealStyle,
seconds: number,
): Promise<MontagePart> => {
const shown = await captureCurrent(card);
return {
kind: "card",
image: await capture({ ...card, hideRevealed: true }),
seconds,
reveal: {
style,
image: await createImageBitmap(shown.image),
rects: shown.revealRects,
},
};
};
const parts: MontagePart[] = [
await captureCard({ kind: "intro" }, "pop", INTRO_CARD_SECONDS),
];
for (const section of montageTeamSections(manifest.topTeams, picks)) {
parts.push(
await captureCard(
{ kind: "team", teamId: section.team.id },
"rise",
TEAM_CARD_SECONDS,
),
);
for (const pick of section.picks) {
const file = files.get(pick.vod.file);
if (!file) throw new Error(`${pick.vod.file} is not in the folder`);
parts.push({
kind: "clip",
file,
segments: playbackSegments(pick.window),
ribbon: await capture({
kind: "ribbon",
vod: pick.vod,
weaponId: pick.weaponId,
}),
});
}
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,
parts,
music: snapshot.music,
writable,
signal: controller.signal,
@@ -392,14 +419,17 @@ export function montageImageUrl(name: string | null): string | undefined {
return url;
}
/** Downloads the results image (every top team's roster and comp) as a PNG. */
export async function downloadMontageResults(): Promise<void> {
/** Downloads the graphic as a PNG, as the video (or for `results`, the image export) captures it. */
export async function downloadMontageGraphic(
graphic: MontageGraphic,
name: string,
): 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" }),
`${fileSlug(folder.manifest)}-${name}.png`,
(await captureGraphic(graphic)).image,
);
}
@@ -414,9 +444,22 @@ export function montageTeamComps(state: MontageSnapshot) {
: 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 candidateFrame(
candidate: MontageCandidate,
width: number | undefined,
): Promise<string | null> {
const file = snapshot.folder?.files?.get(candidate.vod.file);
if (!file) return Promise.resolve(null);
const key = `${candidate.key}@${width ?? "thumbnail"}`;
let frame = thumbnails.get(key);
if (!frame) {
frame = thumbnailQueue.then(() =>
vodFrameThumbnail(file, candidate.window.t, width),
);
thumbnailQueue = frame;
thumbnails.set(key, frame);
}
return frame;
}
function getSnapshot(): MontageSnapshot {
@@ -435,6 +478,10 @@ async function restore(): Promise<void> {
set({ criteria });
return;
}
if (isOutdatedManifest(stored.manifest)) {
set({ criteria, folderError: outdatedManifestError(stored.manifest) });
return;
}
set({
...EMPTY,
criteria,
@@ -509,16 +556,18 @@ async function readFolder(directory: FileSystemDirectoryHandle) {
);
}
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`,
);
if (isOutdatedManifest(manifest)) {
throw new Error(outdatedManifestError(manifest));
}
const names = new Set(
[
manifest.logo,
manifest.organization?.logo ?? null,
...manifest.topTeams.map((team) => team.logo),
...manifest.teams.map((team) => team.logo),
...manifest.topTeams.flatMap((team) => [
team.logo,
...team.players.map((player) => player.avatar),
]),
...manifest.vods.flatMap((vod) => [
vod.file,
vod.pov?.avatar ?? null,
@@ -534,6 +583,21 @@ async function readFolder(directory: FileSystemDirectoryHandle) {
return { manifest, files };
}
/** Written by an older `pnpm vods:download`, missing fields the montage needs. */
function isOutdatedManifest(manifest: MontageManifest): boolean {
return (
!manifest.teams ||
manifest.vods.some((vod) => !vod.teams) ||
manifest.topTeams.some((team) =>
team.players.some((player) => !("avatar" in player)),
)
);
}
function outdatedManifestError(manifest: MontageManifest): string {
return `${MONTAGE_MANIFEST_FILE} is from an older version: run pnpm vods:download ${manifest.tournamentId} again`;
}
async function scannedGames(vod: MontageManifestVod): Promise<MontageGame[]> {
const events = await loadVodEvents(vod.file);
return buildScannerMatches(events).map(({ match, sources }) => ({
@@ -559,9 +623,14 @@ function candidatesOf(
scanned: MontageSnapshot["scanned"],
criteria: MontageCriteria,
): MontageCandidate[] {
const topTeamIds = new Set(
(folder?.manifest.topTeams ?? []).map((team) => team.id),
);
return (folder?.vods ?? [])
.flatMap((vod) =>
(scanned.get(vod.file) ?? []).flatMap((game) =>
.flatMap((vod) => {
const teamIds = candidateTeamIds(vod, topTeamIds);
if (teamIds.length === 0) return [];
return (scanned.get(vod.file) ?? []).flatMap((game) =>
montageWindows(game, criteria).map((window) => ({
key: `${vod.file}@${window.start}-${window.end}`,
vod,
@@ -570,9 +639,10 @@ function candidatesOf(
stage: game.stage,
weaponId: game.povWeaponId,
duration: playbackDuration(playbackSegments(window)),
teamIds,
})),
),
)
);
})
.sort((a, b) => b.window.score - a.window.score);
}
@@ -586,25 +656,32 @@ function lineupOf(team: ScannerMatch["teams"][number]) {
/**
* 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.
* shape (one from before team sections plays under its POV team). Picks of
* VoDs no longer in the folder or of no top team go.
*/
function currentPicks(picks: readonly MontageCandidate[]): MontageCandidate[] {
function currentPicks(
picks: ReadonlyArray<MontageCandidate & { teamId?: number }>,
): MontagePick[] {
const vodByFile = new Map(
(snapshot.folder?.vods ?? []).map((vod) => [vod.file, vod]),
);
const topTeamIds = new Set(
(snapshot.folder?.manifest.topTeams ?? []).map((team) => team.id),
);
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,
},
];
const teamId = pick.teamId ?? pick.vod.team?.id;
if (!vod || teamId === undefined || !topTeamIds.has(teamId)) return [];
const candidate = candidateByKey.get(pick.key) ?? {
...pick,
vod,
weaponId: pick.weaponId ?? null,
teamIds: candidateTeamIds(vod, topTeamIds),
};
return [{ ...candidate, teamId }];
});
}
@@ -612,11 +689,7 @@ 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 {
function setPicks(picks: MontagePick[]): void {
set({ picks });
persist();
}

View File

@@ -3,9 +3,11 @@
* 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.
* longer), how the video is laid out (the intro, then the top teams from
* the last placement up, each one's card followed by its clips) and how a
* picked clip plays back in it — 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 {
@@ -26,6 +28,8 @@ export const DEFAULT_MONTAGE_CRITERIA: MontageCriteria = {
maxSecondsByKills: { 2: 20, 3: 30, 4: 45, 5: 60 },
};
export const FAST_FORWARD_SPEED = 4;
export const INTRO_CARD_SECONDS = 6;
export const TEAM_CARD_SECONDS = 5;
const KILL_MARGIN_S = 3;
/** a shorter stretch of dead time isn't worth the jump in speed */
const MIN_FAST_FORWARD_S = 2;
@@ -42,15 +46,25 @@ export interface MontageManifest {
teamsCount: number;
playersCount: number;
/** best first, ties share a placement; players are the ones who played */
topTeams: Array<MontageManifestTeam & { placement: number }>;
topTeams: MontageManifestTopTeam[];
/** every team that played, in seed order */
teams: Array<Pick<MontageManifestTeam, "id" | "name" | "logo">>;
vods: MontageManifestVod[];
}
export type MontageManifestTopTeam = MontageManifestTeam & {
placement: number;
};
export interface MontageManifestTeam {
id: number;
name: string;
logo: string | null;
players: Array<{ name: string; countryCode: string | null }>;
players: Array<{
name: string;
countryCode: string | null;
avatar: string | null;
}>;
}
export interface MontageManifestVod {
@@ -108,8 +122,13 @@ export interface MontageCandidate {
weaponId: MainWeaponId | null;
/** seconds the clip lasts in the montage, dead time fast-forwarded */
duration: number;
/** the top teams the clip can play under (`candidateTeamIds`) */
teamIds: number[];
}
/** A candidate in the montage, under the card of one of its `teamIds`. */
export type MontagePick = MontageCandidate & { teamId: number };
export interface PlaybackSegment {
/** seconds into the source file */
from: number;
@@ -118,6 +137,44 @@ export interface PlaybackSegment {
speed: number;
}
/**
* The top teams the VoD's clips can play under: the POV team, or for a
* cast either side. Empty when none of them is a top team.
*/
export function candidateTeamIds(
vod: Pick<MontageManifestVod, "team" | "teams">,
topTeamIds: ReadonlySet<number>,
): number[] {
const teamIds = vod.team ? [vod.team.id] : vod.teams.map((team) => team.id);
return teamIds.filter((teamId) => topTeamIds.has(teamId));
}
/**
* The video's team sections after the intro: the top teams counting down
* from the last placement, each with its picks in montage order.
*/
export function montageTeamSections<T extends Pick<MontagePick, "teamId">>(
topTeams: readonly MontageManifestTopTeam[],
picks: readonly T[],
) {
return topTeams.toReversed().map((team) => ({
team,
picks: picks.filter((pick) => pick.teamId === team.id),
}));
}
/** Seconds the video lasts: the cards and the picks, fast-forwarded. */
export function montageDuration(
teamCount: number,
picks: ReadonlyArray<Pick<MontageCandidate, "duration">>,
): number {
return (
INTRO_CARD_SECONDS +
teamCount * TEAM_CARD_SECONDS +
picks.reduce((sum, pick) => sum + pick.duration, 0)
);
}
/** The clip's playback, chronological and gapless: normal speed except fast-forwarded dead time between kills. */
export function playbackSegments(clip: {
start: number;

View File

@@ -19,8 +19,8 @@ export interface StoredMontage {
vods: MontageManifestVod[];
/** scanned games by VoD file name */
games: Record<string, MontageGame[]>;
/** in montage order */
picks: MontageCandidate[];
/** in montage order within each team; `teamId` is absent in records from before team sections */
picks: Array<MontageCandidate & { teamId?: number }>;
/** 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 */

View File

@@ -1,7 +1,10 @@
import { describe, expect, test } from "vitest";
import {
candidatesByBaseWeapon,
candidateTeamIds,
FAST_FORWARD_SPEED,
type MontageManifestTopTeam,
montageTeamSections,
montageWindows,
playbackDuration,
playbackSegments,
@@ -130,3 +133,63 @@ describe("candidatesByBaseWeapon", () => {
]);
});
});
describe("candidateTeamIds", () => {
const topTeamIds = new Set([1, 2]);
const side = (id: number) => ({ id, name: `Team ${id}`, logo: null });
test.each([
{
why: "a POV VoD plays under its team",
vod: { team: side(1), teams: [side(1), side(2)] },
expected: [1],
},
{
why: "a cast plays under either top team",
vod: { team: null, teams: [side(1), side(2)] },
expected: [1, 2],
},
{
why: "teams outside the top are left out",
vod: { team: null, teams: [side(2), side(3)] },
expected: [2],
},
{
why: "a POV VoD of a team outside the top plays under none",
vod: { team: side(3), teams: [side(3), side(1)] },
expected: [],
},
])("$why", ({ vod, expected }) => {
expect(candidateTeamIds(vod, topTeamIds)).toEqual(expected);
});
});
describe("montageTeamSections", () => {
test("counts down from the last placement, each team with its picks in order", () => {
const topTeams = [1, 2, 3].map(
(id): MontageManifestTopTeam => ({
id,
name: `Team ${id}`,
logo: null,
players: [],
placement: id,
}),
);
const picks = [
{ key: "a", teamId: 1 },
{ key: "b", teamId: 3 },
{ key: "c", teamId: 1 },
];
expect(
montageTeamSections(topTeams, picks).map((section) => [
section.team.id,
section.picks.map((pick) => pick.key),
]),
).toEqual([
[3, ["b"]],
[2, []],
[1, ["a", "c"]],
]);
});
});

View File

@@ -226,15 +226,24 @@ async function writeMontageManifest() {
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,
})),
players: await Promise.all(
(fullTeamById.get(standing.team.id)?.members ?? [])
.filter((member) => playedUserIds.has(member.userId))
.map(async (member) => ({
name: member.username,
countryCode: member.country,
avatar: await downloadImage(
resolveAvatarUrl({ ...member, size: "sm" }) ?? null,
`user-${member.userId}`,
),
})),
),
};
}),
),
teams: await Promise.all(
tournament.ctx.teams.map((team) => teamOf(team.id)),
),
vods: await Promise.all(
vods.map(async (vod) => {
const team =