← Files Pray Production StudioARCHIVED FILE
skills/pps-ads/packages/remotion-kit/src/ad/beats/Results.tsx
5.57 KB · Oct 8, 2026 · 06:29 UTC
import React from 'react'
import { AbsoluteFill, Easing, Img, interpolate, useCurrentFrame } from 'remotion'
import { Video } from '@remotion/media'
import { CanvasCamera } from '../../motion/CanvasCamera'
import { Cursor, type CursorPath } from '../../motion/Cursor'
import { PopIn } from '../../motion/PopIn'
import { staggerOnGrid } from '../../motion/grid'
import { frames } from '../../motion/timing'
import { MOTION } from '../../grammar'
import { BrandTag } from '../BrandTag'
import { PressRing, cursorTip, useStage } from '../stage'
import type { BeatProps } from '../types'
const POP_SECONDS = 0.28
const CURSOR_TRAVEL_SECONDS = 0.6
const PRESS_BEFORE_END_SECONDS = 0.5
export interface TileBox {
x: number
y: number
size: number
}
/**
* Where each square tile sits on the stage: 2 columns up to 4 tiles, 3 above, 84% of the width with a 2%
* gap, shrunk to fit 78% of the height on a wide canvas, centered. The cursor aims at these same boxes.
*/
export function tileLayout(count: number, width: number, height: number): TileBox[] {
const cols = count <= 4 ? 2 : 3
const rows = Math.max(1, Math.ceil(count / cols))
const gap = width * 0.02
const size = Math.min((width * 0.84 - gap * (cols - 1)) / cols, (height * 0.78 - gap * (rows - 1)) / rows)
const gridW = cols * size + gap * (cols - 1)
const gridH = rows * size + gap * (rows - 1)
const left = (width - gridW) / 2
const top = (height - gridH) / 2
return Array.from({ length: count }, (_, i) => ({
x: left + (i % cols) * (size + gap),
y: top + Math.floor(i / cols) * (size + gap),
size,
}))
}
type Tile = { kind: 'image'; url: string } | { kind: 'clip'; url: string }
/** 3 to 6 results land tile by tile on the music grid, then the cursor picks one and it lifts. */
export const Results: React.FC<BeatProps> = ({ beat, theme, brand, seconds, bpm, compact }) => {
const frame = useCurrentFrame()
const { width, height, u } = useStage()
const tiles: Tile[] = [
...(beat.imageUrls ?? []).map((url): Tile => ({ kind: 'image', url })),
...(beat.clipUrls ?? []).map((url): Tile => ({ kind: 'clip', url })),
]
const boxes = tileLayout(tiles.length, width, height)
const times = staggerOnGrid(tiles.length, bpm)
const pick = Math.min(Math.max(0, beat.pickIndex ?? 0), Math.max(0, tiles.length - 1))
const landed = frames((times[times.length - 1] ?? 0) + POP_SECONDS + 0.1)
const cursorSize = u * 0.06
const tip = cursorTip(cursorSize)
const pressFrame = Math.min(landed + frames(CURSOR_TRAVEL_SECONDS), frames(seconds - PRESS_BEFORE_END_SECONDS))
const target = boxes[pick]
const center = target
? { x: target.x + target.size / 2, y: target.y + target.size / 2 }
: { x: width / 2, y: height / 2 }
const path: CursorPath = {
from: { x: width * 0.62 - tip.x, y: height - tip.y },
to: { x: center.x - tip.x, y: center.y - tip.y },
startFrame: pressFrame - frames(CURSOR_TRAVEL_SECONDS),
endFrame: pressFrame,
}
const after = interpolate(frame, [pressFrame, pressFrame + frames(POP_SECONDS)], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
})
const radius = Math.max(12, width * 0.018)
const ring = Math.max(4, u * 0.006)
return (
<AbsoluteFill style={{ background: theme.canvasBg }}>
<CanvasCamera beatFrames={frames(seconds)} family="card" disabled={compact}>
{tiles.map((tile, i) => {
const box = boxes[i]
const picked = i === pick
const lift = picked ? 1 + (MOTION.overshootMax - 1) * after : 1
return (
<div
key={i}
style={{
position: 'absolute',
left: box.x,
top: box.y,
width: box.size,
height: box.size,
transform: `scale(${lift})`,
opacity: picked ? 1 : 1 - 0.15 * after,
zIndex: picked ? 2 : 1,
}}
>
<PopIn delaySeconds={times[i]} durationMs={POP_SECONDS * 1000} style={{ width: '100%', height: '100%' }}>
<div
style={{
width: '100%',
height: '100%',
borderRadius: radius,
overflow: 'hidden',
background: theme.surface,
border: `1px solid ${theme.hairline}`,
boxShadow: picked
? `0 0 0 ${ring * after}px ${theme.accent}, 0 16px 40px rgba(0,0,0,${0.12 + 0.1 * after})`
: '0 10px 30px rgba(0,0,0,0.12)',
boxSizing: 'border-box',
}}
>
{tile.kind === 'image' ? (
<Img src={tile.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
// A tile is a picture of a clip: the ad's sound is the music, voiceover and footage beats.
<Video src={tile.url} muted loop objectFit="cover" style={{ width: '100%', height: '100%' }} />
)}
</div>
</PopIn>
</div>
)
})}
<div style={{ position: 'absolute', left: center.x, top: center.y, width: 0, height: 0, zIndex: 3 }}>
<PressRing pressFrame={pressFrame} size={cursorSize * 1.6} color={theme.accent} />
</div>
<AbsoluteFill style={{ zIndex: 4, pointerEvents: 'none' }}>
<Cursor path={path} size={cursorSize} />
</AbsoluteFill>
</CanvasCamera>
{compact ? null : <BrandTag theme={theme} brand={brand} />}
</AbsoluteFill>
)
}
SHA-256: 284fd2d38f85d6c0d4857ce7003270d6996f63e335e4deae80af3dff0e62974f