PR-C1: adaptive video grid sizing (foundation) #2

Closed
hudson wants to merge 18 commits from flotilla-sph-adaptive-grid into dev
2 changed files with 30 additions and 28 deletions
Showing only changes of commit 0d9e511f41 - Show all commits

View file

@ -85,6 +85,8 @@ export type AdaptiveTileGrid = {
type LayoutScore = { type LayoutScore = {
/** Penalty for vertical overflow: 0 if none, else huge */ /** Penalty for vertical overflow: 0 if none, else huge */
verticalOverflowPenalty: number verticalOverflowPenalty: number
/** Penalty for horizontal overflow: 0 if none, else huge */
horizontalOverflowPenalty: number
/** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */ /** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */
minTileHeightPenalty: number minTileHeightPenalty: number
/** Negative tile area (we want to maximize area, so negate) */ /** Negative tile area (we want to maximize area, so negate) */
@ -98,6 +100,7 @@ type LayoutScore = {
const compareScores = (a: LayoutScore, b: LayoutScore): number => { const compareScores = (a: LayoutScore, b: LayoutScore): number => {
const fields: (keyof LayoutScore)[] = [ const fields: (keyof LayoutScore)[] = [
"verticalOverflowPenalty", "verticalOverflowPenalty",
"horizontalOverflowPenalty",
"minTileHeightPenalty", "minTileHeightPenalty",
"negativeArea", "negativeArea",
"whitespace", "whitespace",
@ -125,8 +128,8 @@ const buildUniformRow = (
const rowHeight = availableHeight / rowCount const rowHeight = availableHeight / rowCount
const tileWidth = (containerWidth - (columnCount - 1) * gap) / columnCount const tileWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
const fillAspect = tileWidth / rowHeight const fillAspect = tileWidth / rowHeight
// No upper bound — allow wider than 16:9 to avoid overflow // Cap at 16:9 to avoid horizontally stretching video (review feedback)
const aspectRatio = Math.max(TILE_ASPECT_PORTRAIT, fillAspect) const aspectRatio = Math.max(TILE_ASPECT_PORTRAIT, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect))
const tileHeight = tileWidth / aspectRatio const tileHeight = tileWidth / aspectRatio
return {columnCount, tileWidth, tileHeight, aspectRatio} return {columnCount, tileWidth, tileHeight, aspectRatio}
} }
@ -160,31 +163,21 @@ const buildMixedCandidate = (
const tileWidthLast = (containerWidth - (remainder - 1) * gap) / remainder const tileWidthLast = (containerWidth - (remainder - 1) * gap) / remainder
if (tileWidthLast <= 0) return undefined if (tileWidthLast <= 0) return undefined
// Last row at 16:9 (widest aspect, per review: "single centered 16:9 tile") // Find a common height that fits both row types and container
const lastRowAspect = TILE_ASPECT_LANDSCAPE const maxPossibleHeight = Math.min(
const commonHeight = tileWidthLast / lastRowAspect tileWidthFull / TILE_ASPECT_PORTRAIT,
tileWidthLast / TILE_ASPECT_PORTRAIT,
(containerHeight - (totalRowCount - 1) * gap) / totalRowCount,
)
// Full-row tile that matches the common height // Target 16:9 for the last row if possible
const fullRowAspect = tileWidthFull / commonHeight const targetHeight = tileWidthLast / TILE_ASPECT_LANDSCAPE
const commonHeight = Math.min(maxPossibleHeight, targetHeight)
let tileHeightFull: number const tileHeightFull = commonHeight
let tileHeightLast: number const tileHeightLast = commonHeight
let fullRowAspectRatio: number const fullRowAspectRatio = tileWidthFull / commonHeight
let lastRowAspectRatio: number const lastRowAspectRatio = tileWidthLast / commonHeight
if (fullRowAspect >= TILE_ASPECT_PORTRAIT) {
// Full-row aspect is within bounds — match heights cleanly
tileHeightFull = commonHeight
tileHeightLast = commonHeight
fullRowAspectRatio = fullRowAspect
lastRowAspectRatio = lastRowAspect
} else {
// Full rows would be too portrait — clamp to portrait bound
fullRowAspectRatio = TILE_ASPECT_PORTRAIT
tileHeightFull = tileWidthFull / fullRowAspectRatio
tileHeightLast = commonHeight
lastRowAspectRatio = lastRowAspect
}
const totalHeight = fullRowCount * tileHeightFull + tileHeightLast + (totalRowCount - 1) * gap const totalHeight = fullRowCount * tileHeightFull + tileHeightLast + (totalRowCount - 1) * gap
const totalWidth = Math.max( const totalWidth = Math.max(
@ -293,11 +286,16 @@ export const computeAdaptiveGrid = (
// unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT, // unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT,
// overflow is acceptable (tiles are already too small to shrink further). // overflow is acceptable (tiles are already too small to shrink further).
const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight)) const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight))
const overflowPenalty = const verticalOverflowPenalty =
verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
? verticalOverflow * 1_000_000 ? verticalOverflow * 1_000_000
: verticalOverflow : verticalOverflow
const horizontalOverflowPenalty =
horizontalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
? horizontalOverflow * 1_000_000
: horizontalOverflow
// Min-tile-height penalty: even when fitting, discourage layouts // Min-tile-height penalty: even when fitting, discourage layouts
// where tiles are below the minimum acceptable size. // where tiles are below the minimum acceptable size.
const minTileHeightPenalty = const minTileHeightPenalty =
@ -306,7 +304,8 @@ export const computeAdaptiveGrid = (
: 0 : 0
const score: LayoutScore = { const score: LayoutScore = {
verticalOverflowPenalty: overflowPenalty, verticalOverflowPenalty,
horizontalOverflowPenalty,
minTileHeightPenalty, minTileHeightPenalty,
negativeArea: -totalArea, negativeArea: -totalArea,
whitespace, whitespace,

View file

@ -296,10 +296,13 @@
{#if tileGrid} {#if tileGrid}
<div class="flex flex-col items-center gap-2"> <div class="flex flex-col items-center gap-2">
{#each tileGrid.rows as row, rowIndex} {#each tileGrid.rows as row, rowIndex}
{@const offset = tileGrid.rows
.slice(0, rowIndex)
.reduce((sum, r) => sum + r.columnCount, 0)}
<div <div
class="flex flex-wrap justify-center gap-2" class="flex flex-wrap justify-center gap-2"
style={`max-width: ${row.columnCount * row.tileWidth + (row.columnCount - 1) * 8}px`}> style={`max-width: ${row.columnCount * row.tileWidth + (row.columnCount - 1) * 8}px`}>
{#each videoTiles.slice(rowIndex * row.columnCount, rowIndex * row.columnCount + row.columnCount) as tile (tileKey(tile))} {#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
<div <div
class="overflow-hidden rounded-2xl shrink-0" class="overflow-hidden rounded-2xl shrink-0"
style={`width: ${row.tileWidth}px; height: ${row.tileHeight}px`}> style={`width: ${row.tileWidth}px; height: ${row.tileHeight}px`}>