diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 032194fe6..cb0606853 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -190,7 +190,7 @@ the ring would have dropped it by then: builds the basis of a tournament highlight video. `pnpm vods:download ` writes the match VoDs of the top 8 teams (`--all-teams` for every team) to `scripts/output/vods//` 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 diff --git a/app/features/scanner/capture/montage-overlays.ts b/app/features/scanner/capture/montage-overlays.ts index 7624392e1..523281109 100644 --- a/app/features/scanner/capture/montage-overlays.ts +++ b/app/features/scanner/capture/montage-overlays.ts @@ -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( + (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; +} diff --git a/app/features/scanner/capture/montage-render.ts b/app/features/scanner/capture/montage-render.ts index 22f17c2e1..60dbf0325 100644 --- a/app/features/scanner/capture/montage-render.ts +++ b/app/features/scanner/capture/montage-render.ts @@ -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 | 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, frameCount: number, ): Promise { 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; diff --git a/app/features/scanner/capture/vod-clips.ts b/app/features/scanner/capture/vod-clips.ts index d724335d3..7f32c8e59 100644 --- a/app/features/scanner/capture/vod-clips.ts +++ b/app/features/scanner/capture/vod-clips.ts @@ -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 { + 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(); diff --git a/app/features/scanner/components/MontageGraphics.module.css b/app/features/scanner/components/MontageGraphics.module.css index deaef5f36..fc6a8ad3d 100644 --- a/app/features/scanner/components/MontageGraphics.module.css +++ b/app/features/scanner/components/MontageGraphics.module.css @@ -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); + } +} diff --git a/app/features/scanner/components/MontageGraphics.tsx b/app/features/scanner/components/MontageGraphics.tsx index b82597c73..1efcfe7ed 100644 --- a/app/features/scanner/components/MontageGraphics.tsx +++ b/app/features/scanner/components/MontageGraphics.tsx @@ -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 = { + 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; 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(
- {request.graphic.kind === "title" ? ( - - ) : request.graphic.kind === "standings" ? ( - - - - ) : request.graphic.kind === "results" ? ( - - ) : ( + {request.graphic.kind === "results" ? ( + + ) : request.graphic.kind === "ribbon" ? ( + ) : ( + )}
, @@ -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; +}) { return ( - - -
- - {manifest.teamsCount} - {manifest.playersCount} - - -
Highlights
- -
- +
+ + +
+ ); +} + +/** + * 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 ( +
+
+
+ +
+
+
+ ); +} + +/** The wave as the renderer draws it over the card, played back in real time. */ +function CardWave() { + const canvasRef = React.useRef(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 ( + + ); +} + +function CardGraphic({ + card, + manifest, + comps, + isPreview = false, +}: { + card: MontageCard; + manifest: MontageManifest; + comps: ReadonlyMap; + 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 ( + + {team ? ( + + ) : ( + + )} ); } -/** 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 ( + + + } + titleRow={ + + {manifest.name} + {typeof manifest.tier === "number" ? ( + + + + ) : null} + + } + subtitle={ +
+ + {manifest.organization ? ( + <> + · + + {manifest.organization.name} + + ) : null} +
+ } + trailing={ + <> +
+ + + + +
+
+ + + {manifest.teamsCount} + + + + {manifest.playersCount} + +
+ + } + /> +
+ {manifest.teams.map((team, i) => ( + + + + ))} +
+
+ ); +} + +/** 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 ( + + + + + + } + titleRow={{team.name}} + subtitle={
{manifest.name}
} + /> +
+
4 ? 3 : 2}, minmax(0, 1fr))`, + }} + > + {team.players.map((player, i) => ( +
+ + {player.name} + {player.countryCode ? ( + + ) : null} +
+ ))} +
+
+ {weapons.length > 0 ? ( +
+
+ {weapons.map((weaponId, i) => ( +
+ + +
+ ))} +
+
+ ) : null} +
+ ); +} + +/** The results image export's layout: every top team with its roster and comp. */ function ResultsCard({ manifest, comps, - rosterCount, - width, }: { manifest: MontageManifest; comps: ReadonlyMap; - rosterCount: number; - width?: number; }) { return ( - +
- {manifest.topTeams.map((team, i) => ( + {manifest.topTeams.map((team) => ( ({ - 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 ( - -
-
- {vod.bracketName ?? "Match"} -
- {vod.roundName ?? `Match ${vod.matchId}`} -
-
-
- {vod.teams.map((team, i) => ( - - {i > 0 ? vs : null} - - - {team.name} - - - ))} -
-
- {vod.pov ? ( - - ) : null} -
- {vod.pov ? "POV" : "Cast"} -
- {vod.pov?.name ?? `twitch.tv/${vod.account}`} -
- {vod.pov ? : null} -
- {weaponId === null ? null : ( -
- -
- )} +
+
+
{vod.bracketName ?? "Match"}
+
+ {vod.roundName ?? `Match ${vod.matchId}`}
- +
+ {povTeamFirst(vod).map((team, i) => ( + + {i > 0 ? vs : null} + + + {team.name} + + + ))} +
+
+
+
{vod.pov ? "POV" : "Cast"}
+
+ {vod.pov?.name ?? `twitch.tv/${vod.account}`} +
+
+ {vod.pov ? ( + + ) : null} + {weaponId === null ? null : ( +
+ +
+ )} +
+
+ ); +} + +/** 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 (
{children}
); } +/** 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, diff --git a/app/features/scanner/components/MontageView.module.css b/app/features/scanner/components/MontageView.module.css index 90eadfc0e..5e4e3e56c 100644 --- a/app/features/scanner/components/MontageView.module.css +++ b/app/features/scanner/components/MontageView.module.css @@ -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); +} diff --git a/app/features/scanner/components/MontageView.tsx b/app/features/scanner/components/MontageView.tsx index 4015557c6..29250a995 100644 --- a/app/features/scanner/components/MontageView.tsx +++ b/app/features/scanner/components/MontageView.tsx @@ -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 ) : null} - {montage.folder?.files ? : null} + {montage.folder?.files ? ( + + Results image + + ) : null} + ("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={} - isDisabled={state === "capturing"} + isDisabled={isDisabled || state === "capturing"} onClick={() => void download()} > - {state === "failed" ? "Results image failed, retry" : "Results image"} + {state === "failed" ? "Failed, retry" : children} ); } +/** 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 ( +
+
+ Cards + } + className="ml-auto" + onClick={() => setReplayCount((count) => count + 1)} + > + Replay + + + PNG + +
+ + + Intro + + {teams.map((candidate) => ( + + {candidate.name} + + ))} + {ribbonVod ? ( + + POV ribbon + + ) : null} + + {showsRibbon ? ( + + ) : ( + + )} +
+ ); +} + +/** 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 ( + + ); +} + 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 (
Montage · {picks.length} {picks.length === 1 ? "clip" : "clips"} ·{" "} - {formatPosition(montageDuration(picks))} + {formatPosition(montageDuration(topTeams.length, picks))} {rendering ? ( } - 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} - {picks.length === 0 ? ( -

- Pick clips below; they play in this order, between the title card and - the top {montage.folder?.manifest.topTeams.length}. -

- ) : ( -
    - {picks.map((candidate, i) => ( -
  1. - {i + 1} - - - } - aria-label="Move earlier" - isDisabled={rendering || i === 0} - onClick={() => movePick(candidate.key, -1)} - /> - } - aria-label="Move later" - isDisabled={rendering || i === picks.length - 1} - onClick={() => movePick(candidate.key, 1)} - /> - } - aria-label="Remove from montage" - isDisabled={rendering} - onClick={() => unpickCandidate(candidate.key)} - /> - -
  2. - ))} -
- )} +

+ The intro, then the top {topTeams.length} counting down to the winner: + each team's card, then its clips in this order. +

+ {montageTeamSections(topTeams, picks).map((section) => ( +
+ + {section.picks.length === 0 ? ( +

No clips, only the card

+ ) : ( +
    + {section.picks.map((pick, i) => ( +
  1. + {i + 1} + + + } + aria-label="Move earlier" + isDisabled={rendering || i === 0} + onClick={() => movePick(pick.key, -1)} + /> + } + aria-label="Move later" + isDisabled={rendering || i === section.picks.length - 1} + onClick={() => movePick(pick.key, 1)} + /> + } + aria-label="Remove from montage" + isDisabled={rendering} + onClick={() => unpickCandidate(pick.key)} + /> + +
  2. + ))} +
+ )} +
+ ))}
); } +function TeamLabel({ team }: { team: MontageManifestTopTeam }) { + return ( + + + + + + {team.name} + + ); +} + /** 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(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}
+ + Team + + setTeamFilter(null)} + > + All · {available.length} + + {topTeams.map((team) => ( + setTeamFilter(team.id)} + > + {team.name} ·{" "} + { + available.filter((candidate) => + candidate.teamIds.includes(team.id), + ).length + } + + ))} + + Min splats @@ -453,7 +658,7 @@ function CandidatesPanel({ montage }: { montage: MontageSnapshot }) {

{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."}

) : ( groups.map((group) => { @@ -491,6 +696,11 @@ function CandidatesPanel({ montage }: { montage: MontageSnapshot }) { + 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(null); // the preview's URL is released with the card @@ -548,15 +761,20 @@ function CandidateCard({ )}
- } - isDisabled={isDisabled} - onClick={() => pickCandidate(candidate)} - > - Add - + + {teams.map((team) => ( + } + isDisabled={isDisabled} + onClick={() => pickCandidate(candidate, team.id)} + > + {teams.length === 1 ? "Add" : team.name} + + ))} +
); @@ -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(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 { + const [frame, setFrame] = useState(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; } diff --git a/app/features/scanner/components/montage.ts b/app/features/scanner/components/montage.ts index fab3756ea..4c02b9384 100644 --- a/app/features/scanner/components/montage.ts +++ b/app/features/scanner/components/montage.ts @@ -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; +/** 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 }>; }) => Promise; +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 { - 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 { + 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 { 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 { 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, + style: RevealStyle, + seconds: number, + ): Promise => { + 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 { +/** 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 { 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(); } -/** 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 { + 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 { 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 { 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, +): 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(); } diff --git a/app/features/scanner/core/montage.ts b/app/features/scanner/core/montage.ts index cd7b7f47c..e639be6db 100644 --- a/app/features/scanner/core/montage.ts +++ b/app/features/scanner/core/montage.ts @@ -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; + topTeams: MontageManifestTopTeam[]; + /** every team that played, in seed order */ + teams: Array>; 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, + topTeamIds: ReadonlySet, +): 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>( + 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>, +): 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; diff --git a/app/features/scanner/store/montages.ts b/app/features/scanner/store/montages.ts index a585fd28f..0fb599e85 100644 --- a/app/features/scanner/store/montages.ts +++ b/app/features/scanner/store/montages.ts @@ -19,8 +19,8 @@ export interface StoredMontage { vods: MontageManifestVod[]; /** scanned games by VoD file name */ games: Record; - /** in montage order */ - picks: MontageCandidate[]; + /** in montage order within each team; `teamId` is absent in records from before team sections */ + picks: Array; /** 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 */ diff --git a/app/features/scanner/tests/logic/montage.test.ts b/app/features/scanner/tests/logic/montage.test.ts index 26e887703..ff03e21ca 100644 --- a/app/features/scanner/tests/logic/montage.test.ts +++ b/app/features/scanner/tests/logic/montage.test.ts @@ -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"]], + ]); + }); +}); diff --git a/scripts/download-tournament-vods.ts b/scripts/download-tournament-vods.ts index d4997d359..b3d712ff5 100644 --- a/scripts/download-tournament-vods.ts +++ b/scripts/download-tournament-vods.ts @@ -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 =