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 49 additions and 121 deletions
Showing only changes of commit 63b00e0214 - Show all commits

View file

@ -32,11 +32,8 @@ export const LIVEKIT_PARTICIPANTS = 39004
export {supportsAudioOutputSelection}
/**
* Aspect ratio constraints — tiles can flex between 3:4 (portrait)
* and 16:9 (landscape). Tiles are ALLOWED to be wider than 16:9
* when the container is very wide, to prevent overflow (review #2
* bug: n=2 second tile half off screen). The lower bound (3:4)
* prevents tiles from being too tall.
* Aspect ratio constraints for tiles. The lower bound is dynamic
* (1:1 on landscape, 3:4 on portrait); the upper bound is 16:9.
*/
const TILE_ASPECT_PORTRAIT = 3 / 4
const TILE_ASPECT_LANDSCAPE = 16 / 9
@ -51,13 +48,14 @@ const TILE_GAP = 8
const MIN_TILE_HEIGHT = 120
/**
* A single row in an adaptive tile grid: tiles in this row share
* the same dimensions but may differ from other rows.
* A single row in an adaptive tile grid.
*/
export type TileRow = {
columnCount: number
tileWidth: number
tileHeight: number
/** Total width of this row including gaps — for the template's max-width */
rowWidth: number
aspectRatio: number
}
@ -73,8 +71,6 @@ export type AdaptiveTileGrid = {
rows: TileRow[]
totalWidth: number
totalHeight: number
/** True when the grid overflows — tiles are below MIN_TILE_HEIGHT */
overflows: boolean
}
/**
@ -128,30 +124,11 @@ const fitTile = (availWidth: number, availHeight: number, minAspect: number) =>
}
/**
* Build a single-row TileRow for the uniform case where all rows
* have the same number of tiles. Tiles are centered within the row
* and capped at 16:9 to avoid overflow on ultrawide containers.
* Build a candidate grid for a given column count. All tiles share the
* same dimensions; the partial last row (if any) is centered with the
* same tile size as full rows.
*/
const buildUniformRow = (
columnCount: number,
containerWidth: number,
containerHeight: number,
rowCount: number,
minAspect: number,
): TileRow => {
const gap = TILE_GAP
const availHeight = (containerHeight - (rowCount - 1) * gap) / rowCount
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
return {columnCount, tileWidth, tileHeight, aspectRatio}
}
/**
* Build a mixed-size layout candidate for a given column count.
* All tiles share the same dimensions — full rows fill the width,
* and the partial last row is centered with the same tile size.
*/
const buildMixedCandidate = (
const buildCandidate = (
tileCount: number,
columnCount: number,
containerWidth: number,
@ -161,40 +138,48 @@ const buildMixedCandidate = (
const gap = TILE_GAP
const fullRowCount = Math.floor(tileCount / columnCount)
const remainder = tileCount % columnCount
const totalRowCount = fullRowCount + 1
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
if (availWidth <= 0 || availHeight <= 0) return undefined
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
const rows: TileRow[] = []
for (let r = 0; r < fullRowCount; r++) {
rows.push({columnCount, tileWidth, tileHeight, aspectRatio})
rows.push({
columnCount,
tileWidth,
tileHeight,
rowWidth: columnCount * tileWidth + (columnCount - 1) * gap,
aspectRatio,
})
}
if (remainder > 0) {
rows.push({
columnCount: remainder,
tileWidth,
tileHeight,
rowWidth: remainder * tileWidth + (remainder - 1) * gap,
aspectRatio,
})
}
rows.push({columnCount: remainder, tileWidth, tileHeight, aspectRatio})
const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap
const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap
return {
rows,
totalWidth,
totalHeight,
overflows: totalHeight > containerHeight,
}
return {rows, totalWidth, totalHeight}
}
/**
* Compute an adaptive tile grid using Jitsi-style mixed row sizes.
*
* - Tiles flex between 3:4 (portrait) and wider than 16:9 if needed
* to avoid overflow. No upper bound on aspect ratio — tiles can
* Tiles flex between a minimum aspect (1:1 on landscape, 3:4 on portrait)
* and 16:9, capped so they never overflow the container. Partial rows
* use the same tile size as full rows, centered.
* Compute an adaptive tile grid. All tiles share the same dimensions;
* partial rows are centered. Tiles flex between a minimum aspect
* (1:1 on landscape, 3:4 on portrait) and 16:9, capped so they never
* overflow the container.
*
* Only allows overflow (scroll) when tiles would be below
* MIN_TILE_HEIGHT (review #2: ~6-8 tiles on portrait phone).
* MIN_TILE_HEIGHT (~6-8 tiles on portrait phone).
*
* Prioritises:
* 1. No overflow (tiles shrink to fit within aspect bounds)
@ -209,91 +194,34 @@ export const computeAdaptiveGrid = (
): AdaptiveTileGrid | undefined => {
if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) return undefined
// When the container is at least 4:3 landscape, bound tiles at 1:1 (square)
// so they don't become tall and narrow. On narrower/portrait viewports,
// allow 3:4 so more tiles fit on a phone screen.
const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT
let best: AdaptiveTileGrid | undefined
let bestScore: LayoutScore | undefined
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
const fullRowCount = Math.floor(tileCount / columnCount)
const remainder = tileCount % columnCount
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
let candidate: AdaptiveTileGrid | undefined
if (remainder === 0) {
// Uniform layout — all rows identical
const row = buildUniformRow(
columnCount,
containerWidth,
containerHeight,
totalRowCount,
minAspect,
)
const totalHeight = totalRowCount * row.tileHeight + (totalRowCount - 1) * TILE_GAP
const totalWidth = columnCount * row.tileWidth + (columnCount - 1) * TILE_GAP
const rows: TileRow[] = []
for (let r = 0; r < totalRowCount; r++) {
rows.push({...row})
}
candidate = {
rows,
totalWidth,
totalHeight,
overflows: totalHeight > containerHeight,
}
} else {
// Mixed layout — last row has fewer tiles, make them wider
candidate = buildMixedCandidate(
tileCount,
columnCount,
containerWidth,
containerHeight,
minAspect,
)
}
const candidate = buildCandidate(
tileCount,
columnCount,
containerWidth,
containerHeight,
minAspect,
)
if (!candidate) continue
// Compute score
const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0]
const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight)
const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth)
const whitespace =
Math.max(0, containerWidth - candidate.totalWidth) +
Math.max(0, containerHeight - candidate.totalHeight)
const aspectDeviation =
candidate.rows.reduce(
(sum, row) => sum + Math.abs(row.aspectRatio - TILE_ASPECT_LANDSCAPE),
0,
) / candidate.rows.length
const totalArea = candidate.rows.reduce(
(sum, row) => sum + row.tileWidth * row.tileHeight * row.columnCount,
0,
)
const totalArea = tileWidth * tileHeight * tileCount
// For overflow: if tiles are above MIN_TILE_HEIGHT, overflow is
// unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT,
// overflow is acceptable (tiles are already too small to shrink further).
const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight))
const verticalOverflowPenalty =
verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
? verticalOverflow * 1_000_000
: verticalOverflow
const horizontalOverflowPenalty =
horizontalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
? horizontalOverflow * 1_000_000
: horizontalOverflow
// Min-tile-height penalty: even when fitting, discourage layouts
// where tiles are below the minimum acceptable size.
const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1
const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier
const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier
const minTileHeightPenalty =
minTileHeightInCandidate < MIN_TILE_HEIGHT
? (MIN_TILE_HEIGHT - minTileHeightInCandidate) * 1000
: 0
tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0
const score: LayoutScore = {
verticalOverflowPenalty,
@ -301,7 +229,7 @@ export const computeAdaptiveGrid = (
minTileHeightPenalty,
negativeArea: -totalArea,
whitespace,
aspectDeviation,
aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE),
}
if (!bestScore || compareScores(score, bestScore) < 0) {

View file

@ -279,7 +279,7 @@
.reduce((sum, r) => sum + r.columnCount, 0)}
<div
class="flex flex-nowrap justify-center gap-2"
style={`max-width: ${row.columnCount * row.tileWidth + (row.columnCount - 1) * 8}px`}>
style={`max-width: ${row.rowWidth}px`}>
{#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
<div
class="overflow-hidden rounded-2xl shrink-0"