|
| 1 | +import { CONTACT_SHEET_PIXEL_BUDGET_REASON } from '@agent-device/contracts/recording-contact-sheet'; |
| 2 | +import { AppError } from '@agent-device/kernel/errors'; |
| 3 | +import { encodePngPixels } from '../png-encode.ts'; |
| 4 | +import { PNG } from '../png.ts'; |
| 5 | +import { resizePngBox } from '../png-resize.ts'; |
| 6 | +import { |
| 7 | + drawPngGlyphText, |
| 8 | + measurePngGlyphTextHeight, |
| 9 | + setPngPixel, |
| 10 | + type PngGlyphColor, |
| 11 | +} from '../screenshot-overlay-draw.ts'; |
| 12 | +import { CONTACT_SHEET_FRAME_WIDTH } from './contact-sheet-plan.ts'; |
| 13 | +import type { ContactSheetCell } from './contact-sheet-selection.ts'; |
| 14 | + |
| 15 | +/** Cells per row. Four keeps a portrait phone frame readable without a canvas wider than a screen. */ |
| 16 | +const CONTACT_SHEET_COLUMNS = 4; |
| 17 | +/** Narrowest a cell may shrink to before the sheet refuses rather than print unreadable frames. */ |
| 18 | +export const MIN_CONTACT_SHEET_CELL_WIDTH = 160; |
| 19 | +/** |
| 20 | + * Cell widths tried, widest first. A cell never grows past the width frames were decoded at, and it |
| 21 | + * shrinks in readable steps before the sheet refuses. |
| 22 | + */ |
| 23 | +const LAYOUT_CELL_WIDTHS = [ |
| 24 | + CONTACT_SHEET_FRAME_WIDTH, |
| 25 | + 320, |
| 26 | + 280, |
| 27 | + 240, |
| 28 | + 200, |
| 29 | + MIN_CONTACT_SHEET_CELL_WIDTH, |
| 30 | +] as const; |
| 31 | + |
| 32 | +const SHEET_PADDING = 8; |
| 33 | +const CELL_GAP = 8; |
| 34 | +const LABEL_INSET = 4; |
| 35 | +const LABEL_SCALE = 2; |
| 36 | +const LABEL_COLOR = [255, 255, 255, 255] as const; |
| 37 | +const SHEET_BACKGROUND = [17, 24, 39, 255] as const; |
| 38 | +const LABEL_HEIGHT = measurePngGlyphTextHeight(LABEL_SCALE) + LABEL_INSET * 2; |
| 39 | + |
| 40 | +export type ContactSheetRenderInput = Readonly<{ |
| 41 | + cells: readonly ContactSheetCell[]; |
| 42 | + maxPixels: number; |
| 43 | +}>; |
| 44 | + |
| 45 | +export type ContactSheetRenderResult = Readonly<{ |
| 46 | + bytes: Buffer; |
| 47 | + width: number; |
| 48 | + height: number; |
| 49 | + cellWidth: number; |
| 50 | +}>; |
| 51 | + |
| 52 | +/** Lays the kept cells out in one row-major grid and encodes it as PNG. */ |
| 53 | +export function renderContactSheet(input: ContactSheetRenderInput): ContactSheetRenderResult { |
| 54 | + const { cells } = input; |
| 55 | + if (cells.length === 0) { |
| 56 | + throw new AppError('COMMAND_FAILED', 'Contact sheet has no cells to render'); |
| 57 | + } |
| 58 | + |
| 59 | + const aspect = cells[0]!.image.height / Math.max(1, cells[0]!.image.width); |
| 60 | + const layout = fitLayout(aspect, cells.length, input); |
| 61 | + const canvas = createCanvas(layout.width, layout.height, SHEET_BACKGROUND); |
| 62 | + |
| 63 | + cells.forEach((cell, index) => { |
| 64 | + const column = index % layout.columns; |
| 65 | + const row = Math.floor(index / layout.columns); |
| 66 | + drawCell( |
| 67 | + canvas, |
| 68 | + SHEET_PADDING + column * (layout.cellWidth + CELL_GAP), |
| 69 | + SHEET_PADDING + row * (LABEL_HEIGHT + layout.cellHeight + CELL_GAP), |
| 70 | + cell, |
| 71 | + layout.cellWidth, |
| 72 | + layout.cellHeight, |
| 73 | + ); |
| 74 | + }); |
| 75 | + |
| 76 | + return { |
| 77 | + bytes: encodePngPixels(canvas.data, layout.width, layout.height, 4), |
| 78 | + width: layout.width, |
| 79 | + height: layout.height, |
| 80 | + cellWidth: layout.cellWidth, |
| 81 | + }; |
| 82 | +} |
| 83 | + |
| 84 | +function drawCell( |
| 85 | + canvas: PNG, |
| 86 | + x: number, |
| 87 | + y: number, |
| 88 | + cell: ContactSheetCell, |
| 89 | + cellWidth: number, |
| 90 | + cellHeight: number, |
| 91 | +): void { |
| 92 | + drawPngGlyphText(canvas, { |
| 93 | + x: x + LABEL_INSET, |
| 94 | + y: y + LABEL_INSET, |
| 95 | + text: formatContactSheetTimestamp(cell.timeMs), |
| 96 | + color: LABEL_COLOR, |
| 97 | + scale: LABEL_SCALE, |
| 98 | + }); |
| 99 | + blit(canvas, resizePngBox(toPng(cell.image), cellWidth, cellHeight), x, y + LABEL_HEIGHT); |
| 100 | +} |
| 101 | + |
| 102 | +type ContactSheetLayout = Readonly<{ |
| 103 | + width: number; |
| 104 | + height: number; |
| 105 | + columns: number; |
| 106 | + cellWidth: number; |
| 107 | + cellHeight: number; |
| 108 | +}>; |
| 109 | + |
| 110 | +/** |
| 111 | + * Sizes the grid so it fits the caller's pixel budget. |
| 112 | + * |
| 113 | + * A cell shrinks before a sheet refuses: an over-budget clip is ordinary (a tall iPad recording |
| 114 | + * with many changes), and re-laying the same cells out at a smaller cell is always available. |
| 115 | + */ |
| 116 | +function fitLayout( |
| 117 | + aspect: number, |
| 118 | + cellCount: number, |
| 119 | + input: ContactSheetRenderInput, |
| 120 | +): ContactSheetLayout { |
| 121 | + const fitted = fitToBudget(aspect, cellCount, input.maxPixels); |
| 122 | + if (fitted) return fitted; |
| 123 | + const smallest = layoutAt(aspect, cellCount, MIN_CONTACT_SHEET_CELL_WIDTH); |
| 124 | + throw new AppError( |
| 125 | + 'COMMAND_FAILED', |
| 126 | + `Contact sheet would be ${smallest.width * smallest.height} pixels, above the configured maxImagePixels limit of ${input.maxPixels}`, |
| 127 | + { |
| 128 | + reason: CONTACT_SHEET_PIXEL_BUDGET_REASON, |
| 129 | + cellCount: input.cells.length, |
| 130 | + maxPixels: input.maxPixels, |
| 131 | + hint: 'Shorten the recording or raise the command policy maxImagePixels limit.', |
| 132 | + }, |
| 133 | + ); |
| 134 | +} |
| 135 | + |
| 136 | +/** |
| 137 | + * Walks the cell-width ladder until the grid fits, because the label strip does not shrink with the |
| 138 | + * cell and a single proportional guess can land over budget. The ladder is finite and strictly |
| 139 | + * descending, so the narrowest readable cell is always the last thing tried. |
| 140 | + */ |
| 141 | +function fitToBudget( |
| 142 | + aspect: number, |
| 143 | + cellCount: number, |
| 144 | + budget: number, |
| 145 | +): ContactSheetLayout | undefined { |
| 146 | + for (const cellWidth of LAYOUT_CELL_WIDTHS) { |
| 147 | + const layout = layoutAt(aspect, cellCount, cellWidth); |
| 148 | + if (layout.width * layout.height <= budget) return layout; |
| 149 | + } |
| 150 | + return undefined; |
| 151 | +} |
| 152 | + |
| 153 | +function layoutAt(aspect: number, cellCount: number, cellWidth: number): ContactSheetLayout { |
| 154 | + const columns = Math.min(CONTACT_SHEET_COLUMNS, cellCount); |
| 155 | + const rows = Math.ceil(cellCount / columns); |
| 156 | + const cellHeight = Math.max(1, Math.round(cellWidth * aspect)); |
| 157 | + return { |
| 158 | + columns, |
| 159 | + cellWidth, |
| 160 | + cellHeight, |
| 161 | + width: SHEET_PADDING * 2 + columns * cellWidth + (columns - 1) * CELL_GAP, |
| 162 | + height: SHEET_PADDING * 2 + rows * (LABEL_HEIGHT + cellHeight) + (rows - 1) * CELL_GAP, |
| 163 | + }; |
| 164 | +} |
| 165 | + |
| 166 | +function createCanvas(width: number, height: number, color: PngGlyphColor): PNG { |
| 167 | + const canvas = new PNG({ width, height }); |
| 168 | + for (let row = 0; row < height; row += 1) { |
| 169 | + for (let column = 0; column < width; column += 1) { |
| 170 | + setPngPixel(canvas, column, row, color); |
| 171 | + } |
| 172 | + } |
| 173 | + return canvas; |
| 174 | +} |
| 175 | + |
| 176 | +function blit(canvas: PNG, source: PNG, x: number, y: number): void { |
| 177 | + for (let row = 0; row < source.height; row += 1) { |
| 178 | + for (let column = 0; column < source.width; column += 1) { |
| 179 | + const sourceOffset = (row * source.width + column) * 4; |
| 180 | + setPngPixel(canvas, x + column, y + row, [ |
| 181 | + source.data[sourceOffset]!, |
| 182 | + source.data[sourceOffset + 1]!, |
| 183 | + source.data[sourceOffset + 2]!, |
| 184 | + source.data[sourceOffset + 3]!, |
| 185 | + ]); |
| 186 | + } |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +function toPng(image: ContactSheetCell['image']): PNG { |
| 191 | + const png = new PNG({ width: image.width, height: image.height }); |
| 192 | + Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength).copy(png.data); |
| 193 | + return png; |
| 194 | +} |
| 195 | + |
| 196 | +/** Elapsed time from the clip start as `HH:MM:SS.mmm`, the shape the glyph table spells. */ |
| 197 | +export function formatContactSheetTimestamp(timeMs: number): string { |
| 198 | + const wholeSeconds = Math.floor(timeMs / 1000); |
| 199 | + const hours = Math.floor(wholeSeconds / 3600); |
| 200 | + const minutes = Math.floor((wholeSeconds % 3600) / 60); |
| 201 | + const seconds = wholeSeconds % 60; |
| 202 | + const milliseconds = Math.floor(timeMs % 1000); |
| 203 | + const clock = [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':'); |
| 204 | + return `${clock}.${String(milliseconds).padStart(3, '0')}`; |
| 205 | +} |
0 commit comments