mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 13:06:13 -05:00
Scanner montage v2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user